summaryrefslogtreecommitdiff
path: root/packages/client/src/pages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-13 12:23:49 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-13 12:23:49 +0900
commit2795fe457909c687f668d020ef65d52abc3182fb (patch)
tree0a52e4e4d854333496fcc487560c93c3de5d5eb5 /packages/client/src/pages
parentMerge branch 'develop' (diff)
parent12.96.0 (diff)
downloadmisskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.gz
misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.bz2
misskey-2795fe457909c687f668d020ef65d52abc3182fb.zip
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages')
-rw-r--r--packages/client/src/pages/_error_.vue94
-rw-r--r--packages/client/src/pages/_loading_.vue10
-rw-r--r--packages/client/src/pages/about-misskey.vue238
-rw-r--r--packages/client/src/pages/about.vue123
-rw-r--r--packages/client/src/pages/admin/abuses.vue170
-rw-r--r--packages/client/src/pages/admin/ads.vue138
-rw-r--r--packages/client/src/pages/admin/announcements.vue125
-rw-r--r--packages/client/src/pages/admin/bot-protection.vue138
-rw-r--r--packages/client/src/pages/admin/database.vue61
-rw-r--r--packages/client/src/pages/admin/email-settings.vue128
-rw-r--r--packages/client/src/pages/admin/emoji-edit-dialog.vue120
-rw-r--r--packages/client/src/pages/admin/emojis.vue263
-rw-r--r--packages/client/src/pages/admin/file-dialog.vue129
-rw-r--r--packages/client/src/pages/admin/files-settings.vue93
-rw-r--r--packages/client/src/pages/admin/files.vue209
-rw-r--r--packages/client/src/pages/admin/index.vue388
-rw-r--r--packages/client/src/pages/admin/instance-block.vue72
-rw-r--r--packages/client/src/pages/admin/instance.vue291
-rw-r--r--packages/client/src/pages/admin/integrations-discord.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-github.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-twitter.vue85
-rw-r--r--packages/client/src/pages/admin/integrations.vue74
-rw-r--r--packages/client/src/pages/admin/metrics.vue472
-rw-r--r--packages/client/src/pages/admin/object-storage.vue155
-rw-r--r--packages/client/src/pages/admin/other-settings.vue83
-rw-r--r--packages/client/src/pages/admin/overview.vue236
-rw-r--r--packages/client/src/pages/admin/proxy-account.vue87
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue102
-rw-r--r--packages/client/src/pages/admin/queue.vue73
-rw-r--r--packages/client/src/pages/admin/relays.vue99
-rw-r--r--packages/client/src/pages/admin/security.vue83
-rw-r--r--packages/client/src/pages/admin/service-worker.vue85
-rw-r--r--packages/client/src/pages/admin/settings.vue151
-rw-r--r--packages/client/src/pages/admin/users.vue254
-rw-r--r--packages/client/src/pages/advanced-theme-editor.vue352
-rw-r--r--packages/client/src/pages/announcements.vue74
-rw-r--r--packages/client/src/pages/antenna-timeline.vue147
-rw-r--r--packages/client/src/pages/api-console.vue93
-rw-r--r--packages/client/src/pages/auth.form.vue60
-rw-r--r--packages/client/src/pages/auth.vue95
-rw-r--r--packages/client/src/pages/channel-editor.vue129
-rw-r--r--packages/client/src/pages/channel.vue186
-rw-r--r--packages/client/src/pages/channels.vue77
-rw-r--r--packages/client/src/pages/clip.vue154
-rw-r--r--packages/client/src/pages/drive.vue28
-rw-r--r--packages/client/src/pages/emojis.category.vue135
-rw-r--r--packages/client/src/pages/emojis.emoji.vue94
-rw-r--r--packages/client/src/pages/emojis.vue36
-rw-r--r--packages/client/src/pages/explore.vue261
-rw-r--r--packages/client/src/pages/favorites.vue60
-rw-r--r--packages/client/src/pages/featured.vue43
-rw-r--r--packages/client/src/pages/federation.vue265
-rw-r--r--packages/client/src/pages/follow-requests.vue153
-rw-r--r--packages/client/src/pages/follow.vue65
-rw-r--r--packages/client/src/pages/gallery/edit.vue168
-rw-r--r--packages/client/src/pages/gallery/index.vue152
-rw-r--r--packages/client/src/pages/gallery/post.vue282
-rw-r--r--packages/client/src/pages/instance-info.vue238
-rw-r--r--packages/client/src/pages/mentions.vue42
-rw-r--r--packages/client/src/pages/messages.vue45
-rw-r--r--packages/client/src/pages/messaging/index.vue307
-rw-r--r--packages/client/src/pages/messaging/messaging-room.form.vue348
-rw-r--r--packages/client/src/pages/messaging/messaging-room.message.vue350
-rw-r--r--packages/client/src/pages/messaging/messaging-room.vue470
-rw-r--r--packages/client/src/pages/mfm-cheat-sheet.vue365
-rw-r--r--packages/client/src/pages/miauth.vue100
-rw-r--r--packages/client/src/pages/my-antennas/create.vue51
-rw-r--r--packages/client/src/pages/my-antennas/edit.vue56
-rw-r--r--packages/client/src/pages/my-antennas/editor.vue190
-rw-r--r--packages/client/src/pages/my-antennas/index.vue71
-rw-r--r--packages/client/src/pages/my-clips/index.vue104
-rw-r--r--packages/client/src/pages/my-groups/group.vue184
-rw-r--r--packages/client/src/pages/my-groups/index.vue121
-rw-r--r--packages/client/src/pages/my-lists/index.vue88
-rw-r--r--packages/client/src/pages/my-lists/list.vue170
-rw-r--r--packages/client/src/pages/not-found.vue25
-rw-r--r--packages/client/src/pages/note.vue209
-rw-r--r--packages/client/src/pages/notifications.vue88
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.button.vue84
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue50
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.counter.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.if.vue84
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.image.vue72
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.note.vue65
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.post.vue43
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue50
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.section.vue96
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.switch.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue39
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text.vue57
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue40
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue57
-rw-r--r--packages/client/src/pages/page-editor/page-editor.blocks.vue78
-rw-r--r--packages/client/src/pages/page-editor/page-editor.container.vue159
-rw-r--r--packages/client/src/pages/page-editor/page-editor.script-block.vue281
-rw-r--r--packages/client/src/pages/page-editor/page-editor.vue561
-rw-r--r--packages/client/src/pages/page.vue311
-rw-r--r--packages/client/src/pages/pages.vue96
-rw-r--r--packages/client/src/pages/preview.vue32
-rw-r--r--packages/client/src/pages/reset-password.vue69
-rw-r--r--packages/client/src/pages/reversi/game.board.vue528
-rw-r--r--packages/client/src/pages/reversi/game.setting.vue390
-rw-r--r--packages/client/src/pages/reversi/game.vue76
-rw-r--r--packages/client/src/pages/reversi/index.vue279
-rw-r--r--packages/client/src/pages/room/preview.vue107
-rw-r--r--packages/client/src/pages/room/room.vue285
-rw-r--r--packages/client/src/pages/scratchpad.vue149
-rw-r--r--packages/client/src/pages/search.vue53
-rw-r--r--packages/client/src/pages/settings/2fa.vue247
-rw-r--r--packages/client/src/pages/settings/account-info.vue185
-rw-r--r--packages/client/src/pages/settings/accounts.vue149
-rw-r--r--packages/client/src/pages/settings/api.vue65
-rw-r--r--packages/client/src/pages/settings/apps.vue113
-rw-r--r--packages/client/src/pages/settings/custom-css.vue73
-rw-r--r--packages/client/src/pages/settings/deck.vue107
-rw-r--r--packages/client/src/pages/settings/delete-account.vue68
-rw-r--r--packages/client/src/pages/settings/drive.vue147
-rw-r--r--packages/client/src/pages/settings/email-address.vue70
-rw-r--r--packages/client/src/pages/settings/email-notification.vue91
-rw-r--r--packages/client/src/pages/settings/email.vue66
-rw-r--r--packages/client/src/pages/settings/experimental-features.vue52
-rw-r--r--packages/client/src/pages/settings/general.vue223
-rw-r--r--packages/client/src/pages/settings/import-export.vue112
-rw-r--r--packages/client/src/pages/settings/index.vue326
-rw-r--r--packages/client/src/pages/settings/integration.vue141
-rw-r--r--packages/client/src/pages/settings/menu.vue117
-rw-r--r--packages/client/src/pages/settings/mute-block.vue85
-rw-r--r--packages/client/src/pages/settings/notifications.vue77
-rw-r--r--packages/client/src/pages/settings/other.vue97
-rw-r--r--packages/client/src/pages/settings/plugin.install.vue147
-rw-r--r--packages/client/src/pages/settings/plugin.manage.vue115
-rw-r--r--packages/client/src/pages/settings/plugin.vue44
-rw-r--r--packages/client/src/pages/settings/privacy.vue120
-rw-r--r--packages/client/src/pages/settings/profile.vue281
-rw-r--r--packages/client/src/pages/settings/reaction.vue152
-rw-r--r--packages/client/src/pages/settings/registry.keys.vue114
-rw-r--r--packages/client/src/pages/settings/registry.value.vue149
-rw-r--r--packages/client/src/pages/settings/registry.vue90
-rw-r--r--packages/client/src/pages/settings/security.vue158
-rw-r--r--packages/client/src/pages/settings/sounds.vue155
-rw-r--r--packages/client/src/pages/settings/theme.install.vue105
-rw-r--r--packages/client/src/pages/settings/theme.manage.vue105
-rw-r--r--packages/client/src/pages/settings/theme.vue424
-rw-r--r--packages/client/src/pages/settings/update.vue95
-rw-r--r--packages/client/src/pages/settings/word-mute.vue110
-rw-r--r--packages/client/src/pages/share.vue184
-rw-r--r--packages/client/src/pages/signup-complete.vue50
-rw-r--r--packages/client/src/pages/tag.vue57
-rw-r--r--packages/client/src/pages/test.vue259
-rw-r--r--packages/client/src/pages/theme-editor.vue306
-rw-r--r--packages/client/src/pages/timeline.tutorial.vue131
-rw-r--r--packages/client/src/pages/timeline.vue225
-rw-r--r--packages/client/src/pages/user-ap-info.vue124
-rw-r--r--packages/client/src/pages/user-info.vue245
-rw-r--r--packages/client/src/pages/user-list-timeline.vue147
-rw-r--r--packages/client/src/pages/user/clips.vue50
-rw-r--r--packages/client/src/pages/user/follow-list.vue65
-rw-r--r--packages/client/src/pages/user/gallery.vue56
-rw-r--r--packages/client/src/pages/user/index.activity.vue34
-rw-r--r--packages/client/src/pages/user/index.photos.vue107
-rw-r--r--packages/client/src/pages/user/index.timeline.vue68
-rw-r--r--packages/client/src/pages/user/index.vue829
-rw-r--r--packages/client/src/pages/user/pages.vue49
-rw-r--r--packages/client/src/pages/user/reactions.vue81
-rw-r--r--packages/client/src/pages/v.vue29
-rw-r--r--packages/client/src/pages/welcome.entrance.a.vue320
-rw-r--r--packages/client/src/pages/welcome.entrance.b.vue236
-rw-r--r--packages/client/src/pages/welcome.entrance.c.vue305
-rw-r--r--packages/client/src/pages/welcome.setup.vue102
-rw-r--r--packages/client/src/pages/welcome.timeline.vue99
-rw-r--r--packages/client/src/pages/welcome.vue38
172 files changed, 25230 insertions, 0 deletions
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
new file mode 100644
index 0000000000..c549751a27
--- /dev/null
+++ b/packages/client/src/pages/_error_.vue
@@ -0,0 +1,94 @@
+<template>
+<MkLoading v-if="!loaded" />
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+ <div class="mjndxjch" v-show="loaded">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p><b><i class="fas fa-exclamation-triangle"></i> {{ $ts.pageLoadError }}</b></p>
+ <p v-if="version === meta.version">{{ $ts.pageLoadErrorDescription }}</p>
+ <p v-else-if="serverIsDead">{{ $ts.serverIsDead }}</p>
+ <template v-else>
+ <p>{{ $ts.newVersionOfClientAvailable }}</p>
+ <p>{{ $ts.youShouldUpgradeClient }}</p>
+ <MkButton @click="reload" class="button primary">{{ $ts.reload }}</MkButton>
+ </template>
+ <p><MkA to="/docs/general/troubleshooting" class="_link">{{ $ts.troubleshooting }}</MkA></p>
+ <p v-if="error" class="error">ERROR: {{ error }}</p>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as symbols from '@/symbols';
+import { version } from '@/config';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+ props: {
+ error: {
+ required: false,
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.error,
+ icon: 'fas fa-exclamation-triangle'
+ },
+ loaded: false,
+ serverIsDead: false,
+ meta: {} as any,
+ version,
+ };
+ },
+ created() {
+ os.api('meta', {
+ detail: false
+ }).then(meta => {
+ this.loaded = true;
+ this.serverIsDead = false;
+ this.meta = meta;
+ localStorage.setItem('v', meta.version);
+ }, () => {
+ this.loaded = true;
+ this.serverIsDead = true;
+ });
+ },
+ methods: {
+ reload() {
+ unisonReload();
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mjndxjch {
+ padding: 32px;
+ text-align: center;
+
+ > p {
+ margin: 0 0 12px 0;
+ }
+
+ > .button {
+ margin: 8px auto;
+ }
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 24px;
+ border-radius: 16px;
+ }
+
+ > .error {
+ opacity: 0.7;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/_loading_.vue b/packages/client/src/pages/_loading_.vue
new file mode 100644
index 0000000000..05c6af1cd7
--- /dev/null
+++ b/packages/client/src/pages/_loading_.vue
@@ -0,0 +1,10 @@
+<template>
+<MkLoading/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({});
+</script>
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
new file mode 100644
index 0000000000..c428c1ad83
--- /dev/null
+++ b/packages/client/src/pages/about-misskey.vue
@@ -0,0 +1,238 @@
+<template>
+<div style="overflow: clip;">
+ <FormBase class="znqjceqz">
+ <div id="debug"></div>
+ <section class="_debobigegoItem about">
+ <div class="_debobigegoPanel panel" :class="{ playing: easterEggEngine != null }" ref="about">
+ <img src="/client-assets/about-icon.png" alt="" class="icon" @load="iconLoaded" draggable="false" @click="gravity"/>
+ <div class="misskey">Misskey</div>
+ <div class="version">v{{ version }}</div>
+ <span class="emoji" v-for="emoji in easterEggEmojis" :key="emoji.id" :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>
+ </section>
+ <section class="_debobigegoItem" style="text-align: center; padding: 0 16px;">
+ {{ $ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ $ts.learnMore }}</a>
+ </section>
+ <FormGroup>
+ <FormLink to="https://github.com/misskey-dev/misskey" external>
+ <template #icon><i class="fas fa-code"></i></template>
+ {{ $ts._aboutMisskey.source }}
+ <template #suffix>GitHub</template>
+ </FormLink>
+ <FormLink to="https://crowdin.com/project/misskey" external>
+ <template #icon><i class="fas fa-language"></i></template>
+ {{ $ts._aboutMisskey.translation }}
+ <template #suffix>Crowdin</template>
+ </FormLink>
+ <FormLink to="https://www.patreon.com/syuilo" external>
+ <template #icon><i class="fas fa-hand-holding-medical"></i></template>
+ {{ $ts._aboutMisskey.donate }}
+ <template #suffix>Patreon</template>
+ </FormLink>
+ </FormGroup>
+ <FormGroup>
+ <template #label>{{ $ts._aboutMisskey.contributors }}</template>
+ <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>
+ <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template>
+ </FormGroup>
+ <FormGroup>
+ <template #label><Mfm text="[jelly โค]"/> {{ $ts._aboutMisskey.patrons }}</template>
+ <FormKeyValueView v-for="patron in patrons" :key="patron"><template #key>{{ patron }}</template></FormKeyValueView>
+ <template #caption>{{ $ts._aboutMisskey.morePatrons }}</template>
+ </FormGroup>
+ </FormBase>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version } from '@/config';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import MkLink from '@/components/link.vue';
+import { physics } from '@/scripts/physics';
+import * as symbols from '@/symbols';
+
+const patrons = [
+ 'Satsuki Yanagi',
+ 'noellabo',
+ 'mametsuko',
+ 'AureoleArk',
+ 'Gargron',
+ 'Nokotaro Takeda',
+ 'Suji Yan',
+ 'Hekovic',
+ 'Gitmo Life Services',
+ 'nenohi',
+ 'naga_rus',
+ 'Melilot',
+ 'Efertone',
+ 'oi_yekssim',
+ 'nanami kan',
+ 'motcha',
+ 'dansup',
+ 'Quinton Macejkovic',
+ 'YUKIMOCHI',
+ 'mewl hayabusa',
+ 'makokunsan',
+ 'Peter G.',
+ 'Nesakko',
+ 'regtan',
+ '่ฆ‹ๅฝ“ใ‹ใชใฟ',
+ 'natalie',
+ 'Jerry',
+ 'takimura',
+ 'sikyosyounin',
+ 'YuzuRyo61',
+ 'sheeta.s',
+ 'osapon',
+ 'mkatze',
+ 'CG',
+ 'nafuchoco',
+ 'Takumi Sugita',
+ 'chidori ninokura',
+ 'mydarkstar',
+ 'kiritan',
+ 'kabo2468y',
+ 'weepjp',
+ 'Liaizon Wakest',
+ 'Steffen K9',
+ 'Roujo',
+ 'uroco @99',
+ 'totokoro',
+ 'public_yusuke',
+ 'wara',
+ 'S Y',
+ 'Denshi',
+ 'Osushimaru',
+ 'ๅดๆตฅ',
+ 'DignifiedSilence',
+ 't_w',
+];
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormKeyValueView,
+ MkLink,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.aboutMisskey,
+ icon: null
+ },
+ version,
+ patrons,
+ easterEggReady: false,
+ easterEggEmojis: [],
+ easterEggEngine: null,
+ }
+ },
+
+ beforeUnmount() {
+ if (this.easterEggEngine) {
+ this.easterEggEngine.stop();
+ }
+ },
+
+ methods: {
+ iconLoaded() {
+ const emojis = this.$store.state.reactions;
+ const containerWidth = this.$refs.about.offsetWidth;
+ for (let i = 0; i < 32; i++) {
+ this.easterEggEmojis.push({
+ id: i.toString(),
+ top: -(128 + (Math.random() * 256)),
+ left: (Math.random() * containerWidth),
+ emoji: emojis[Math.floor(Math.random() * emojis.length)],
+ });
+ }
+
+ this.$nextTick(() => {
+ this.easterEggReady = true;
+ });
+ },
+
+ gravity() {
+ if (!this.easterEggReady) return;
+ this.easterEggReady = false;
+ this.easterEggEngine = physics(this.$refs.about);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.znqjceqz {
+ max-width: 800px;
+ box-sizing: border-box;
+ margin: 0 auto;
+
+ > .about {
+ > .panel {
+ position: relative;
+ text-align: center;
+ padding: 16px;
+
+ &.playing {
+ &, * {
+ user-select: none;
+ }
+
+ * {
+ will-change: transform;
+ }
+
+ > .emoji {
+ visibility: visible;
+ }
+ }
+
+ > .icon {
+ display: block;
+ width: 100px;
+ margin: 0 auto;
+ border-radius: 16px;
+ }
+
+ > .misskey {
+ margin: 0.75em auto 0 auto;
+ width: max-content;
+ }
+
+ > .version {
+ margin: 0 auto;
+ width: max-content;
+ opacity: 0.5;
+ }
+
+ > .emoji {
+ position: absolute;
+ top: 0;
+ left: 0;
+ visibility: hidden;
+
+ > .emoji {
+ pointer-events: none;
+ font-size: 24px;
+ width: 24px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
new file mode 100644
index 0000000000..dbdf0f6d91
--- /dev/null
+++ b/packages/client/src/pages/about.vue
@@ -0,0 +1,123 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel fwhjspax">
+ <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+ <span class="name">{{ $instance.name || host }}</span>
+ </div>
+ </div>
+
+ <FormTextarea readonly :value="$instance.description">
+ </FormTextarea>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Misskey</template>
+ <template #value>v{{ version }}</template>
+ </FormKeyValueView>
+ <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
+ </FormGroup>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value>{{ $instance.maintainerName }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.contact }}</template>
+ <template #value>{{ $instance.maintainerEmail }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" external>{{ $ts.tos }}</FormLink>
+
+ <FormSuspense :p="initStats">
+ <FormGroup>
+ <template #label>{{ $ts.statistics }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.users }}</template>
+ <template #value>{{ number(stats.originalUsersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.notes }}</template>
+ <template #value>{{ number(stats.originalNotesCount) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+
+ <FormGroup>
+ <template #label>Well-known resources</template>
+ <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>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version, instanceName } from '@/config';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import * as symbols from '@/symbols';
+import { host } from '@/config';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormKeyValueView,
+ FormTextarea,
+ FormSuspense,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceInfo,
+ icon: 'fas fa-info-circle'
+ },
+ host,
+ version,
+ instanceName,
+ stats: null,
+ initStats: () => os.api('stats', {
+ }).then((stats) => {
+ this.stats = stats;
+ })
+ }
+ },
+
+ methods: {
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fwhjspax {
+ padding: 16px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ }
+
+ > .name {
+ display: block;
+ margin-top: 12px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
new file mode 100644
index 0000000000..ca94737781
--- /dev/null
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -0,0 +1,170 @@
+<template>
+<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>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="unresolved">{{ $ts.unresolved }}</option>
+ <option value="resolved">{{ $ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporteeOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporterOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $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" @update:modelValue="$refs.reports.reload()">
+ <span>{{ $ts.username }}</span>
+ </MkInput>
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'">
+ <span>{{ $ts.host }}</span>
+ </MkInput>
+ </div>
+ -->
+
+ <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);">
+ <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id">
+ <div class="_content target">
+ <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
+ <div class="info">
+ <MkUserName class="name" :user="report.targetUser"/>
+ <div class="acct">@{{ acct(report.targetUser) }}</div>
+ </div>
+ </div>
+ <div class="_content">
+ <div>
+ <Mfm :text="report.comment"/>
+ </div>
+ <hr>
+ <div>Reporter: <MkAcct :user="report.reporter"/></div>
+ <div><MkTime :time="report.createdAt"/></div>
+ </div>
+ <div class="_footer">
+ <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div>
+ <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton>
+ </div>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.abuseReports,
+ icon: 'fas fa-exclamation-circle',
+ bg: 'var(--bg)',
+ },
+ searchUsername: '',
+ searchHost: '',
+ state: 'unresolved',
+ reporterOrigin: 'combined',
+ targetUserOrigin: 'combined',
+ pagination: {
+ endpoint: 'admin/abuse-user-reports',
+ limit: 10,
+ params: () => ({
+ state: this.state,
+ reporterOrigin: this.reporterOrigin,
+ targetUserOrigin: this.targetUserOrigin,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ state() {
+ this.$refs.reports.reload();
+ },
+
+ reporterOrigin() {
+ this.$refs.reports.reload();
+ },
+
+ targetUserOrigin() {
+ this.$refs.reports.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ acct,
+
+ resolve(report) {
+ os.apiWithDialog('admin/resolve-abuse-user-report', {
+ reportId: report.id,
+ }).then(() => {
+ this.$refs.reports.removeItem(item => item.id === report.id);
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcixvhis {
+ margin: var(--margin);
+}
+
+.bcekxzvu {
+ > .target {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ > .avatar {
+ width: 42px;
+ height: 42px;
+ }
+
+ > .info {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
new file mode 100644
index 0000000000..df6c9d5d00
--- /dev/null
+++ b/packages/client/src/pages/admin/ads.vue
@@ -0,0 +1,138 @@
+<template>
+<div class="uqshojas">
+ <section class="_card _gap ads" v-for="ad in ads">
+ <div class="_content ad">
+ <MkAd v-if="ad.url" :specify="ad"/>
+ <MkInput v-model="ad.url" type="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="ad.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="ad.place" value="square">square</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio>
+ </div>
+ <!--
+ <div style="margin: 32px 0;">
+ {{ $ts.priority }}
+ <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
+ </div>
+ -->
+ <MkInput v-model="ad.ratio" type="number">
+ <template #label>{{ $ts.ratio }}</template>
+ </MkInput>
+ <MkInput v-model="ad.expiresAt" type="date">
+ <template #label>{{ $ts.expiration }}</template>
+ </MkInput>
+ <MkTextarea v-model="ad.memo">
+ <template #label>{{ $ts.memo }}</template>
+ </MkTextarea>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkRadio,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.ads,
+ icon: 'fas fa-audio-description',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ ads: [],
+ }
+ },
+
+ created() {
+ os.api('admin/ad/list').then(ads => {
+ this.ads = ads;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.ads.unshift({
+ id: null,
+ memo: '',
+ place: 'square',
+ priority: 'middle',
+ ratio: 1,
+ url: '',
+ imageUrl: null,
+ expiresAt: null,
+ });
+ },
+
+ remove(ad) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: ad.url }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.ads = this.ads.filter(x => x != ad);
+ os.apiWithDialog('admin/ad/delete', {
+ id: ad.id
+ });
+ });
+ },
+
+ 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()
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
new file mode 100644
index 0000000000..a64008967f
--- /dev/null
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="ztgjmzrw">
+ <section class="_card _gap announcements" v-for="announcement in announcements">
+ <div class="_content announcement">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ $ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text">
+ <template #label>{{ $ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.announcements,
+ icon: 'fas fa-broadcast-tower',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ announcements: [],
+ }
+ },
+
+ created() {
+ os.api('admin/announcements/list').then(announcements => {
+ this.announcements = announcements;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null
+ });
+ },
+
+ remove(announcement) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: announcement.title }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.announcements = this.announcements.filter(x => x != announcement);
+ os.api('admin/announcements/delete', announcement);
+ });
+ },
+
+ save(announcement) {
+ if (announcement.id == null) {
+ os.api('admin/announcements/create', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ } else {
+ os.api('admin/announcements/update', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
new file mode 100644
index 0000000000..8f7873baa3
--- /dev/null
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -0,0 +1,138 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormRadios v-model="provider">
+ <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
+ <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
+ <option value="hcaptcha">hCaptcha</option>
+ <option value="recaptcha">reCAPTCHA</option>
+ </FormRadios>
+
+ <template v-if="provider === 'hcaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">hCaptcha</div>
+ <div class="main">
+ <FormInput v-model="hcaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="hcaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="provider === 'recaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">reCAPTCHA</div>
+ <div class="main">
+ <FormInput v-model="recaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="recaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
+ </div>
+ </div>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormRadios,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')),
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.botProtection,
+ icon: 'fas fa-shield-alt'
+ },
+ provider: null,
+ enableHcaptcha: false,
+ hcaptchaSiteKey: null,
+ hcaptchaSecretKey: null,
+ enableRecaptcha: false,
+ recaptchaSiteKey: null,
+ recaptchaSecretKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableHcaptcha = meta.enableHcaptcha;
+ this.hcaptchaSiteKey = meta.hcaptchaSiteKey;
+ this.hcaptchaSecretKey = meta.hcaptchaSecretKey;
+ this.enableRecaptcha = meta.enableRecaptcha;
+ this.recaptchaSiteKey = meta.recaptchaSiteKey;
+ this.recaptchaSecretKey = meta.recaptchaSecretKey;
+
+ this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null;
+
+ this.$watch(() => this.provider, () => {
+ this.enableHcaptcha = this.provider === 'hcaptcha';
+ this.enableRecaptcha = this.provider === 'recaptcha';
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableHcaptcha: this.enableHcaptcha,
+ hcaptchaSiteKey: this.hcaptchaSiteKey,
+ hcaptchaSecretKey: this.hcaptchaSecretKey,
+ enableRecaptcha: this.enableRecaptcha,
+ recaptchaSiteKey: this.recaptchaSiteKey,
+ recaptchaSecretKey: this.recaptchaSecretKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
new file mode 100644
index 0000000000..b550831e02
--- /dev/null
+++ b/packages/client/src/pages/admin/database.vue
@@ -0,0 +1,61 @@
+<template>
+<FormBase>
+ <FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }">
+ <FormGroup v-for="table in database" :key="table[0]">
+ <template #label>{{ table[0] }}</template>
+ <FormKeyValueView>
+ <template #key>Size</template>
+ <template #value>{{ bytes(table[1].size) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Records</template>
+ <template #value>{{ number(table[1].count) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ FormSuspense,
+ FormKeyValueView,
+ FormBase,
+ FormGroup,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.database,
+ icon: 'fas fa-database',
+ bg: 'var(--bg)',
+ },
+ databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ bytes, number,
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
new file mode 100644
index 0000000000..3733f53a23
--- /dev/null
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -0,0 +1,128 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch>
+
+ <template v-if="enableEmail">
+ <FormInput v-model="email" type="email">
+ <span>{{ $ts.emailAddress }}</span>
+ </FormInput>
+
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div>
+ <div class="main">
+ <FormInput v-model="smtpHost">
+ <span>{{ $ts.smtpHost }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPort" type="number">
+ <span>{{ $ts.smtpPort }}</span>
+ </FormInput>
+ <FormInput v-model="smtpUser">
+ <span>{{ $ts.smtpUser }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPass" type="password">
+ <span>{{ $ts.smtpPass }}</span>
+ </FormInput>
+ <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo>
+ <FormSwitch v-model="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch>
+ </div>
+ </div>
+
+ <FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailServer,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ enableEmail: false,
+ email: null,
+ smtpSecure: false,
+ smtpHost: '',
+ smtpPort: 0,
+ smtpUser: '',
+ smtpPass: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableEmail = meta.enableEmail;
+ this.email = meta.email;
+ this.smtpSecure = meta.smtpSecure;
+ this.smtpHost = meta.smtpHost;
+ this.smtpPort = meta.smtpPort;
+ this.smtpUser = meta.smtpUser;
+ this.smtpPass = meta.smtpPass;
+ },
+
+ async testEmail() {
+ const { canceled, result: destination } = await os.dialog({
+ title: this.$ts.destination,
+ input: {
+ placeholder: this.$instance.maintainerEmail
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('admin/send-email', {
+ to: destination,
+ subject: 'Test email',
+ text: 'Yo'
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableEmail: this.enableEmail,
+ email: this.email,
+ smtpSecure: this.smtpSecure,
+ smtpHost: this.smtpHost,
+ smtpPort: this.smtpPort,
+ smtpUser: this.smtpUser,
+ smtpPass: this.smtpPass,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue
new file mode 100644
index 0000000000..e612855105
--- /dev/null
+++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue
@@ -0,0 +1,120 @@
+<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 class="_formBlock" v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="category" :datalist="categories">
+ <template #label>{{ $ts.category }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="aliases">
+ <template #label>{{ $ts.tags }}</template>
+ <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template>
+ </MkInput>
+ <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { unique } from '@/scripts/array';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkButton,
+ MkInput,
+ },
+
+ props: {
+ emoji: {
+ required: true,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ name: this.emoji.name,
+ category: this.emoji.category,
+ aliases: this.emoji.aliases?.join(' '),
+ categories: [],
+ }
+ },
+
+ created() {
+ os.api('meta', { detail: false }).then(({ emojis }) => {
+ this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
+ });
+ },
+
+ methods: {
+ ok() {
+ this.update();
+ },
+
+ async update() {
+ await os.apiWithDialog('admin/emoji/update', {
+ id: this.emoji.id,
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ });
+
+ this.$emit('done', {
+ updated: {
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ }
+ });
+ this.$refs.dialog.close();
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.emoji.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.api('admin/emoji/remove', {
+ id: this.emoji.id
+ }).then(() => {
+ this.$emit('done', {
+ deleted: true
+ });
+ this.$refs.dialog.close();
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yigymqpb {
+ > .img {
+ display: block;
+ height: 64px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
new file mode 100644
index 0000000000..c9ba193dd1
--- /dev/null
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="ogwlenmc">
+ <div class="local" v-if="tab === 'local'">
+ <MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkPagination :pagination="pagination" ref="emojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="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 class="remote" v-else-if="tab === 'remote'">
+ <MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true" style="margin: var(--margin);">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <MkPagination :pagination="remotePagination" ref="remoteEmojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @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>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, toRef } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTab,
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.customEmojis,
+ icon: 'fas fa-laugh',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addEmoji,
+ handler: this.add,
+ }],
+ tabs: [{
+ active: this.tab === 'local',
+ title: this.$ts.local,
+ onClick: () => { this.tab = 'local'; },
+ }, {
+ active: this.tab === 'remote',
+ title: this.$ts.remote,
+ onClick: () => { this.tab = 'remote'; },
+ },]
+ })),
+ tab: 'local',
+ query: null,
+ queryRemote: null,
+ host: '',
+ pagination: {
+ endpoint: 'admin/emoji/list',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.query && this.query !== '') ? this.query : null
+ }))
+ },
+ remotePagination: {
+ endpoint: 'admin/emoji/list-remote',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null,
+ host: (this.host && this.host !== '') ? this.host : null
+ }))
+ },
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', toRef(this, symbols.PAGE_INFO));
+ },
+
+ methods: {
+ async add(e) {
+ const files = await selectFile(e.currentTarget || e.target, null, true);
+
+ const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
+ fileId: file.id,
+ })));
+ promise.then(() => {
+ this.$refs.emojis.reload();
+ });
+ os.promiseDialog(promise);
+ },
+
+ edit(emoji) {
+ os.popup(import('./emoji-edit-dialog.vue'), {
+ emoji: emoji
+ }, {
+ done: result => {
+ if (result.updated) {
+ this.$refs.emojis.replaceItem(item => item.id === emoji.id, {
+ ...emoji,
+ ...result.updated
+ });
+ } else if (result.deleted) {
+ this.$refs.emojis.removeItem(item => item.id === emoji.id);
+ }
+ },
+ }, 'closed');
+ },
+
+ im(emoji) {
+ os.apiWithDialog('admin/emoji/copy', {
+ emojiId: emoji.id,
+ });
+ },
+
+ remoteMenu(emoji, ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + emoji.name + ':',
+ }, {
+ text: this.$ts.import,
+ icon: 'fas fa-plus',
+ action: () => { this.im(emoji) }
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+</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);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ 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);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 90%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue
new file mode 100644
index 0000000000..016a012ea5
--- /dev/null
+++ b/packages/client/src/pages/admin/file-dialog.vue
@@ -0,0 +1,129 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header v-if="file">{{ file.name }}</template>
+ <div class="cxqhhsmd" v-if="file">
+ <div class="_section">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="info">
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ <MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
+ <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ <div class="_section" v-if="info">
+ <details class="_content rawdata">
+ <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
+ </details>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import Progress from '@/scripts/loading';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ XModalWindow,
+ MkDriveFileThumbnail,
+ },
+
+ props: {
+ fileId: {
+ required: true,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ file: null,
+ info: null,
+ isSensitive: false,
+ };
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ async fetch() {
+ Progress.start();
+ this.file = await os.api('drive/files/show', { fileId: this.fileId });
+ this.info = await os.api('admin/drive/show-file', { fileId: this.fileId });
+ this.isSensitive = this.file.isSensitive;
+ Progress.done();
+ },
+
+ showUser() {
+ os.pageWindow(`/user-info/${this.file.userId}`);
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.file.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('drive/files/delete', {
+ fileId: this.file.id
+ });
+ },
+
+ async toggleIsSensitive(v) {
+ await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v });
+ this.isSensitive = v;
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cxqhhsmd {
+ > ._section {
+ > .thumbnail {
+ height: 150px;
+ max-width: 100%;
+ }
+
+ > .info {
+ text-align: center;
+ margin-top: 8px;
+ }
+
+ > .rawdata {
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/files-settings.vue b/packages/client/src/pages/admin/files-settings.vue
new file mode 100644
index 0000000000..03d8f3de1f
--- /dev/null
+++ b/packages/client/src/pages/admin/files-settings.vue
@@ -0,0 +1,93 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="cacheRemoteFiles">
+ {{ $ts.cacheRemoteFiles }}
+ <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="proxyRemoteFiles">
+ {{ $ts.proxyRemoteFiles }}
+ <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormInput v-model="localDriveCapacityMb" type="number">
+ <span>{{ $ts.driveCapacityPerLocalAccount }}</span>
+ <template #suffix>MB</template>
+ <template #desc>{{ $ts.inMb }}</template>
+ </FormInput>
+
+ <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">
+ <span>{{ $ts.driveCapacityPerRemoteAccount }}</span>
+ <template #suffix>MB</template>
+ <template #desc>{{ $ts.inMb }}</template>
+ </FormInput>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ cacheRemoteFiles: false,
+ proxyRemoteFiles: false,
+ localDriveCapacityMb: 0,
+ remoteDriveCapacityMb: 0,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.cacheRemoteFiles = meta.cacheRemoteFiles;
+ this.proxyRemoteFiles = meta.proxyRemoteFiles;
+ this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+ this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ cacheRemoteFiles: this.cacheRemoteFiles,
+ proxyRemoteFiles: this.proxyRemoteFiles,
+ localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
new file mode 100644
index 0000000000..e291d97bbc
--- /dev/null
+++ b/packages/client/src/pages/admin/files.vue
@@ -0,0 +1,209 @@
+<template>
+<div class="xrmjdkdw">
+ <MkContainer :foldable="true" class="lookup">
+ <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
+ <div class="xrmjdkdw-lookup">
+ <MkInput class="item" v-model="q" type="text" @enter="find()">
+ <template #label>{{ $ts.fileIdOrUrl }}</template>
+ </MkInput>
+ <MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
+ </div>
+ </MkContainer>
+
+ <div class="_section">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="origin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </div>
+ <div class="inputs" style="display: flex; padding-top: 1.2em;">
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files">
+ <button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="body">
+ <div>
+ <small style="opacity: 0.7;">{{ file.name }}</small>
+ </div>
+ <div>
+ <MkAcct v-if="file.user" :user="file.user"/>
+ <div v-else>{{ $ts.system }}</div>
+ </div>
+ <div>
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ </div>
+ <div>
+ <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+ </div>
+ </div>
+ </button>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ MkContainer,
+ MkDriveFileThumbnail,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ actions: [{
+ text: this.$ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: this.clear
+ }]
+ },
+ q: null,
+ origin: 'local',
+ type: null,
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/drive/files',
+ limit: 10,
+ params: () => ({
+ type: (this.type && this.type !== '') ? this.type : null,
+ origin: this.origin,
+ hostname: (this.hostname && this.hostname !== '') ? this.hostname : null,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ type() {
+ this.$refs.files.reload();
+ },
+ origin() {
+ this.$refs.files.reload();
+ },
+ searchHost() {
+ this.$refs.files.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.clearCachedFilesConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/drive/clean-remote-files', {});
+ });
+ },
+
+ show(file, ev) {
+ os.popup(import('./file-dialog.vue'), {
+ fileId: file.id
+ }, {}, 'closed');
+ },
+
+ find() {
+ os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
+ this.show(file);
+ }).catch(e => {
+ if (e.code === 'NO_SUCH_FILE') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.notFound
+ });
+ }
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xrmjdkdw {
+ margin: var(--margin);
+
+ > .lookup {
+ margin-bottom: 16px;
+ }
+
+ .urempief {
+ margin-top: var(--margin);
+
+ > .file {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .thumbnail {
+ width: 128px;
+ height: 128px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+ }
+ }
+ }
+}
+
+.xrmjdkdw-lookup {
+ padding: 16px;
+
+ > .item {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
new file mode 100644
index 0000000000..d3f9406db7
--- /dev/null
+++ b/packages/client/src/pages/admin/index.vue
@@ -0,0 +1,388 @@
+<template>
+<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || page == null">
+ <MkHeader :info="header"></MkHeader>
+
+ <MkSpacer :content-max="700">
+ <div class="lxpfedzu">
+ <div class="banner">
+ <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+ </div>
+
+ <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+
+ <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div class="main">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
+ <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
+ </MkStickyContainer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkSuperMenu from '@/components/ui/super-menu.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import { scroll } from '@/scripts/scroll';
+import { instance } from '@/instance';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkSuperMenu,
+ FormGroup,
+ FormButton,
+ MkInfo,
+ },
+
+ provide: {
+ shouldOmitHeaderTitle: false,
+ },
+
+ props: {
+ initialPage: {
+ type: String,
+ required: false
+ }
+ },
+
+ setup(props, context) {
+ const indexInfo = {
+ title: i18n.locale.controlPanel,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ hideHeader: true,
+ };
+ const INFO = ref(indexInfo);
+ const childInfo = ref(null);
+ const page = ref(props.initialPage);
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const onInfo = (viewInfo) => {
+ if (isRef(viewInfo)) {
+ watch(viewInfo, () => {
+ childInfo.value = viewInfo.value;
+ }, { immediate: true });
+ } else {
+ childInfo.value = viewInfo;
+ }
+ };
+ const pageProps = ref({});
+
+ const isEmpty = (x: any) => x == null || x == '';
+
+ const noMaintainerInformation = ref(false);
+ const noBotProtection = ref(false);
+
+ os.api('meta', { detail: true }).then(meta => {
+ // TODO: ่จญๅฎšใŒๅฎŒไบ†ใ—ใฆใ‚‚ๆฎ‹ใฃใŸใพใพใซใชใ‚‹ใฎใงใ€ใ‚นใƒˆใƒชใƒผใƒŸใƒณใ‚ฐใงmetaๆ›ดๆ–ฐใ‚คใƒ™ใƒณใƒˆใ‚’ๅ—ใ‘ๅ–ใฃใฆใ‚ˆใ—ใชใซๆ›ดๆ–ฐใ™ใ‚‹
+ noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail);
+ noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha;
+ });
+
+ const menuDef = computed(() => [{
+ title: i18n.locale.quickAction,
+ items: [{
+ type: 'button',
+ icon: 'fas fa-search',
+ text: i18n.locale.lookup,
+ action: lookup,
+ }, ...(instance.disableRegistration ? [{
+ type: 'button',
+ icon: 'fas fa-user',
+ text: i18n.locale.invite,
+ action: invite,
+ }] : [])],
+ }, {
+ title: i18n.locale.administration,
+ items: [{
+ icon: 'fas fa-tachometer-alt',
+ text: i18n.locale.dashboard,
+ to: '/admin/overview',
+ active: page.value === 'overview',
+ }, {
+ icon: 'fas fa-users',
+ text: i18n.locale.users,
+ to: '/admin/users',
+ active: page.value === 'users',
+ }, {
+ icon: 'fas fa-laugh',
+ text: i18n.locale.customEmojis,
+ to: '/admin/emojis',
+ active: page.value === 'emojis',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.federation,
+ to: '/admin/federation',
+ active: page.value === 'federation',
+ }, {
+ icon: 'fas fa-clipboard-list',
+ text: i18n.locale.jobQueue,
+ to: '/admin/queue',
+ active: page.value === 'queue',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files',
+ active: page.value === 'files',
+ }, {
+ icon: 'fas fa-broadcast-tower',
+ text: i18n.locale.announcements,
+ to: '/admin/announcements',
+ active: page.value === 'announcements',
+ }, {
+ icon: 'fas fa-audio-description',
+ text: i18n.locale.ads,
+ to: '/admin/ads',
+ active: page.value === 'ads',
+ }, {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.abuseReports,
+ to: '/admin/abuses',
+ active: page.value === 'abuses',
+ }],
+ }, {
+ title: i18n.locale.settings,
+ items: [{
+ icon: 'fas fa-cog',
+ text: i18n.locale.general,
+ to: '/admin/settings',
+ active: page.value === 'settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files-settings',
+ active: page.value === 'files-settings',
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.emailServer,
+ to: '/admin/email-settings',
+ active: page.value === 'email-settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.objectStorage,
+ to: '/admin/object-storage',
+ active: page.value === 'object-storage',
+ }, {
+ icon: 'fas fa-lock',
+ text: i18n.locale.security,
+ to: '/admin/security',
+ active: page.value === 'security',
+ }, {
+ icon: 'fas fa-bolt',
+ text: 'ServiceWorker',
+ to: '/admin/service-worker',
+ active: page.value === 'service-worker',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.relays,
+ to: '/admin/relays',
+ active: page.value === 'relays',
+ }, {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.integration,
+ to: '/admin/integrations',
+ active: page.value === 'integrations',
+ }, {
+ icon: 'fas fa-ban',
+ text: i18n.locale.instanceBlocking,
+ to: '/admin/instance-block',
+ active: page.value === 'instance-block',
+ }, {
+ icon: 'fas fa-ghost',
+ text: i18n.locale.proxyAccount,
+ to: '/admin/proxy-account',
+ active: page.value === 'proxy-account',
+ }, {
+ icon: 'fas fa-cogs',
+ text: i18n.locale.other,
+ to: '/admin/other-settings',
+ active: page.value === 'other-settings',
+ }],
+ }, {
+ title: i18n.locale.info,
+ items: [{
+ icon: 'fas fa-database',
+ text: i18n.locale.database,
+ to: '/admin/database',
+ active: page.value === 'database',
+ }],
+ }]);
+ const component = computed(() => {
+ if (page.value == null) return null;
+ switch (page.value) {
+ case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
+ case 'users': return defineAsyncComponent(() => import('./users.vue'));
+ case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
+ case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
+ case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
+ case 'files': return defineAsyncComponent(() => import('./files.vue'));
+ case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+ case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
+ case 'database': return defineAsyncComponent(() => import('./database.vue'));
+ case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
+ case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
+ case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));
+ case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
+ case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
+ case 'security': return defineAsyncComponent(() => import('./security.vue'));
+ case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue'));
+ case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue'));
+ case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
+ case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
+ case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue'));
+ case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue'));
+ case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue'));
+ case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
+ case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
+ case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
+ }
+ });
+
+ watch(component, () => {
+ pageProps.value = {};
+
+ nextTick(() => {
+ scroll(el.value, { top: 0 });
+ });
+ }, { immediate: true });
+
+ watch(() => props.initialPage, () => {
+ if (props.initialPage == null && !narrow.value) {
+ page.value = 'overview';
+ } else {
+ page.value = props.initialPage;
+ if (props.initialPage == null) {
+ INFO.value = indexInfo;
+ }
+ }
+ });
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 800;
+ if (!narrow.value) {
+ page.value = 'overview';
+ }
+ });
+
+ const invite = () => {
+ os.api('admin/invite').then(x => {
+ os.dialog({
+ type: 'info',
+ text: x.code
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ };
+
+ const lookup = (ev) => {
+ os.popupMenu([{
+ text: i18n.locale.user,
+ icon: 'fas fa-user',
+ action: () => {
+ lookupUser();
+ }
+ }, {
+ text: i18n.locale.note,
+ icon: 'fas fa-pencil-alt',
+ action: () => {
+ alert('TODO');
+ }
+ }, {
+ text: i18n.locale.file,
+ icon: 'fas fa-cloud',
+ action: () => {
+ alert('TODO');
+ }
+ }, {
+ text: i18n.locale.instance,
+ icon: 'fas fa-globe',
+ action: () => {
+ alert('TODO');
+ }
+ }], ev.currentTarget || ev.target);
+ };
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ menuDef,
+ header: {
+ title: i18n.locale.controlPanel,
+ },
+ noMaintainerInformation,
+ noBotProtection,
+ page,
+ narrow,
+ view,
+ el,
+ onInfo,
+ childInfo,
+ pageProps,
+ component,
+ invite,
+ lookup,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hiyeyicy {
+ &.wide {
+ display: flex;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ max-width: 280px;
+ box-sizing: border-box;
+ border-right: solid 0.5px var(--divider);
+ overflow: auto;
+ height: 100%;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ > .nav {
+ .lxpfedzu {
+ > .info {
+ margin: 16px 0;
+ }
+
+ > .banner {
+ margin: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 42px;
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
new file mode 100644
index 0000000000..f5b249698d
--- /dev/null
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -0,0 +1,72 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormTextarea v-model="blockedHosts">
+ <span>{{ $ts.blockedInstances }}</span>
+ <template #desc>{{ $ts.blockedInstancesDescription }}</template>
+ </FormTextarea>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceBlocking,
+ icon: 'fas fa-ban',
+ bg: 'var(--bg)',
+ },
+ blockedHosts: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.blockedHosts = meta.blockedHosts.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ blockedHosts: this.blockedHosts.split('\n') || [],
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/instance.vue b/packages/client/src/pages/admin/instance.vue
new file mode 100644
index 0000000000..26eefe243f
--- /dev/null
+++ b/packages/client/src/pages/admin/instance.vue
@@ -0,0 +1,291 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="520"
+ :height="500"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ instance.host }}</template>
+ <div class="mk-instance-info">
+ <div class="_table section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.software }}</div>
+ <div class="_data">{{ instance.softwareName || '?' }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.version }}</div>
+ <div class="_data">{{ instance.softwareVersion || '?' }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="_table data section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.registeredAt }}</div>
+ <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.following }}</div>
+ <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.followers }}</div>
+ <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.users }}</div>
+ <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.notes }}</div>
+ <div class="_data">{{ number(instance.notesCount) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.files }}</div>
+ <div class="_data">{{ number(instance.driveFiles) }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.storageUsage }}</div>
+ <div class="_data">{{ bytes(instance.driveUsage) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestSentAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestStatus }}</div>
+ <div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestReceivedAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ </div>
+ <div class="chart">
+ <div class="header">
+ <span class="label">{{ $ts.charts }}</span>
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+ <option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+ <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+ <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+ <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+ <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+ <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+ <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+ <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+ <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ </div>
+ <div class="chart">
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
+ </div>
+ </div>
+ <div class="operations section">
+ <span class="label">{{ $ts.operations }}</span>
+ <MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch>
+ <MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch>
+ <details>
+ <summary>{{ $ts.deleteAllFiles }}</summary>
+ <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton>
+ </details>
+ <details>
+ <summary>{{ $ts.removeAllFollowing }}</summary>
+ <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton>
+ <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo>
+ </details>
+ </div>
+ <details class="metadata section">
+ <summary class="label">{{ $ts.metadata }}</summary>
+ <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre>
+ </details>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkChart from '@/components/chart.vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkSelect,
+ MkButton,
+ MkSwitch,
+ MkInfo,
+ MkChart,
+ },
+
+ props: {
+ instance: {
+ type: Object,
+ required: true
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ isSuspended: this.instance.isSuspended,
+ chartSrc: 'requests',
+ chartSpan: 'hour',
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+
+ isBlocked() {
+ return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host);
+ }
+ },
+
+ watch: {
+ isSuspended() {
+ os.api('admin/federation/update-instance', {
+ host: this.instance.host,
+ isSuspended: this.isSuspended
+ });
+ },
+ },
+
+ methods: {
+ changeBlock(e) {
+ os.api('admin/update-meta', {
+ blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
+ });
+ },
+
+ removeAllFollowing() {
+ os.apiWithDialog('admin/federation/remove-all-following', {
+ host: this.instance.host
+ });
+ },
+
+ deleteAllFiles() {
+ os.apiWithDialog('admin/federation/delete-all-files', {
+ host: this.instance.host
+ });
+ },
+
+ showFollowing() {
+ // TODO: ใƒšใƒผใ‚ธ้ท็งป
+ },
+
+ showFollowers() {
+ // TODO: ใƒšใƒผใ‚ธ้ท็งป
+ },
+
+ showUsers() {
+ // TODO: ใƒšใƒผใ‚ธ้ท็งป
+ },
+
+ bytes,
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-info {
+ overflow: auto;
+
+ > .section {
+ padding: 16px 32px;
+
+ @media (max-width: 500px) {
+ padding: 8px 16px;
+ }
+
+ &:not(:first-child) {
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+
+ > .chart {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px 0 12px 0;
+
+ > .header {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .selects {
+ display: flex;
+ }
+ }
+
+ > .chart {
+ padding: 0 16px;
+
+ @media (max-width: 500px) {
+ padding: 0;
+ }
+ }
+ }
+
+ > .operations {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .switch {
+ margin: 16px 0;
+ }
+ }
+
+ > .metadata {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > pre > code {
+ display: block;
+ max-height: 200px;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/integrations-discord.vue b/packages/client/src/pages/admin/integrations-discord.vue
new file mode 100644
index 0000000000..81e47499c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-discord.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableDiscordIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableDiscordIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo>
+
+ <FormInput v-model="discordClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="discordClientSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Discord',
+ icon: 'fab fa-discord'
+ },
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.discordClientId = meta.discordClientId;
+ this.discordClientSecret = meta.discordClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-github.vue b/packages/client/src/pages/admin/integrations-github.vue
new file mode 100644
index 0000000000..2bbc3ae9a1
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-github.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableGithubIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableGithubIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo>
+
+ <FormInput v-model="githubClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="githubClientSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'GitHub',
+ icon: 'fab fa-github'
+ },
+ enableGithubIntegration: false,
+ githubClientId: null,
+ githubClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.githubClientId = meta.githubClientId;
+ this.githubClientSecret = meta.githubClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableGithubIntegration: this.enableGithubIntegration,
+ githubClientId: this.githubClientId,
+ githubClientSecret: this.githubClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-twitter.vue b/packages/client/src/pages/admin/integrations-twitter.vue
new file mode 100644
index 0000000000..19ed216ab9
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-twitter.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableTwitterIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableTwitterIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo>
+
+ <FormInput v-model="twitterConsumerKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Consumer Key
+ </FormInput>
+
+ <FormInput v-model="twitterConsumerSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Consumer Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Twitter',
+ icon: 'fab fa-twitter'
+ },
+ enableTwitterIntegration: false,
+ twitterConsumerKey: null,
+ twitterConsumerSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.twitterConsumerKey = meta.twitterConsumerKey;
+ this.twitterConsumerSecret = meta.twitterConsumerSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableTwitterIntegration: this.enableTwitterIntegration,
+ twitterConsumerKey: this.twitterConsumerKey,
+ twitterConsumerSecret: this.twitterConsumerSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
new file mode 100644
index 0000000000..c21eebc1c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -0,0 +1,74 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/integrations/twitter">
+ <i class="fab fa-twitter"></i> Twitter
+ <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/integrations/github">
+ <i class="fab fa-github"></i> GitHub
+ <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/integrations/discord">
+ <i class="fab fa-discord"></i> Discord
+ <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormLink,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.integration,
+ icon: 'fas fa-share-alt',
+ bg: 'var(--bg)',
+ },
+ enableTwitterIntegration: false,
+ enableGithubIntegration: false,
+ enableDiscordIntegration: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue
new file mode 100644
index 0000000000..05b64b235c
--- /dev/null
+++ b/packages/client/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/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkInstanceInfo from './instance.vue';
+
+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';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ MkInput,
+ MkContainer,
+ MkFolder,
+ MkwFederation,
+ },
+
+ data() {
+ return {
+ version,
+ url,
+ stats: null,
+ serverInfo: null,
+ connection: null,
+ queueConnection: markRaw(os.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(os.stream.useChannel('serverStats'));
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150
+ });
+
+ this.$nextTick(() => {
+ this.queueConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ }
+ this.queueConnection.dispose();
+ },
+
+ methods: {
+ cpumem(el) {
+ if (this.chartCpuMem != null) return;
+ this.chartCpuMem = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: []
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: []
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ max: 100
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ net(el) {
+ if (this.chartNet != null) return;
+ this.chartNet = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ disk(el) {
+ if (this.chartDisk != null) return;
+ this.chartDisk = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ fetchJobs() {
+ os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+
+ onStats(stats) {
+ if (this.paused) return;
+
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ bytes,
+
+ number,
+
+ pause() {
+ this.paused = true;
+ },
+
+ resume() {
+ this.paused = false;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+ > div:nth-child(2) {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
new file mode 100644
index 0000000000..0f1431c258
--- /dev/null
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -0,0 +1,155 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch>
+
+ <template v-if="useObjectStorage">
+ <FormInput v-model="objectStorageBaseUrl">
+ <span>{{ $ts.objectStorageBaseUrl }}</span>
+ <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageBucket">
+ <span>{{ $ts.objectStorageBucket }}</span>
+ <template #desc>{{ $ts.objectStorageBucketDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStoragePrefix">
+ <span>{{ $ts.objectStoragePrefix }}</span>
+ <template #desc>{{ $ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageEndpoint">
+ <span>{{ $ts.objectStorageEndpoint }}</span>
+ <template #desc>{{ $ts.objectStorageEndpointDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageRegion">
+ <span>{{ $ts.objectStorageRegion }}</span>
+ <template #desc>{{ $ts.objectStorageRegionDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageAccessKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Access key</span>
+ </FormInput>
+
+ <FormInput v-model="objectStorageSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Secret key</span>
+ </FormInput>
+
+ <FormSwitch v-model="objectStorageUseSSL">
+ {{ $ts.objectStorageUseSSL }}
+ <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageUseProxy">
+ {{ $ts.objectStorageUseProxy }}
+ <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageSetPublicRead">
+ {{ $ts.objectStorageSetPublicRead }}
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageS3ForcePathStyle">
+ s3ForcePathStyle
+ </FormSwitch>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.objectStorage,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ useObjectStorage: false,
+ objectStorageBaseUrl: null,
+ objectStorageBucket: null,
+ objectStoragePrefix: null,
+ objectStorageEndpoint: null,
+ objectStorageRegion: null,
+ objectStoragePort: null,
+ objectStorageAccessKey: null,
+ objectStorageSecretKey: null,
+ objectStorageUseSSL: false,
+ objectStorageUseProxy: false,
+ objectStorageSetPublicRead: false,
+ objectStorageS3ForcePathStyle: true,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.useObjectStorage = meta.useObjectStorage;
+ this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
+ this.objectStorageBucket = meta.objectStorageBucket;
+ this.objectStoragePrefix = meta.objectStoragePrefix;
+ this.objectStorageEndpoint = meta.objectStorageEndpoint;
+ this.objectStorageRegion = meta.objectStorageRegion;
+ this.objectStoragePort = meta.objectStoragePort;
+ this.objectStorageAccessKey = meta.objectStorageAccessKey;
+ this.objectStorageSecretKey = meta.objectStorageSecretKey;
+ this.objectStorageUseSSL = meta.objectStorageUseSSL;
+ this.objectStorageUseProxy = meta.objectStorageUseProxy;
+ this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+ this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ useObjectStorage: this.useObjectStorage,
+ objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
+ objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
+ objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
+ objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
+ objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
+ objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
+ objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
+ objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
+ objectStorageUseSSL: this.objectStorageUseSSL,
+ objectStorageUseProxy: this.objectStorageUseProxy,
+ objectStorageSetPublicRead: this.objectStorageSetPublicRead,
+ objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
new file mode 100644
index 0000000000..e8f872bf0a
--- /dev/null
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormGroup>
+ <FormInput v-model="summalyProxy">
+ <template #prefix><i class="fas fa-link"></i></template>
+ Summaly Proxy URL
+ </FormInput>
+ </FormGroup>
+ <FormGroup>
+ <FormInput v-model="deeplAuthKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ DeepL Auth Key
+ </FormInput>
+ <FormSwitch v-model="deeplIsPro">
+ Pro account
+ </FormSwitch>
+ </FormGroup>
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.other,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ summalyProxy: '',
+ deeplAuthKey: '',
+ deeplIsPro: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.summalyProxy = meta.summalyProxy;
+ this.deeplAuthKey = meta.deeplAuthKey;
+ this.deeplIsPro = meta.deeplIsPro;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ summalyProxy: this.summalyProxy,
+ deeplAuthKey: this.deeplAuthKey,
+ deeplIsPro: this.deeplIsPro,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
new file mode 100644
index 0000000000..e1352945a1
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="edbbcaef" v-size="{ max: [740] }">
+ <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
+ <div class="number _panel">
+ <div class="label">Users</div>
+ <div class="value _monospace">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Notes</div>
+ <div class="value _monospace">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
+
+ <MkContainer :foldable="true" class="charts">
+ <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template>
+ <div style="padding-top: 12px;">
+ <MkInstanceStats :chart-limit="500" :detailed="true"/>
+ </div>
+ </MkContainer>
+
+ <div class="queue">
+ <MkContainer :foldable="true" :thin="true" class="deliver">
+ <template #header>Queue: deliver</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
+ </MkContainer>
+ <MkContainer :foldable="true" :thin="true" class="inbox">
+ <template #header>Queue: inbox</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
+ </MkContainer>
+ </div>
+
+ <!--<XMetrics/>-->
+
+ <MkFolder style="margin: var(--margin)">
+ <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template>
+ <div class="cfcdecdf">
+ <div class="number _panel">
+ <div class="label">Misskey</div>
+ <div class="value _monospace">{{ version }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Node.js</div>
+ <div class="value _monospace">{{ serverInfo.node }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">PostgreSQL</div>
+ <div class="value _monospace">{{ serverInfo.psql }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Redis</div>
+ <div class="value _monospace">{{ serverInfo.redis }}</div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Vue</div>
+ <div class="value _monospace">{{ vueVersion }}</div>
+ </div>
+ </div>
+ </MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw, version as vueVersion } from 'vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import MkInstanceStats from '@/components/instance-stats.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkNumberDiff from '@/components/number-diff.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkQueueChart from '@/components/queue-chart.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkInstanceInfo from './instance.vue';
+import XMetrics from './metrics.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkNumberDiff,
+ FormKeyValueView,
+ MkInstanceStats,
+ MkContainer,
+ MkFolder,
+ MkQueueChart,
+ XMetrics,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.dashboard,
+ icon: 'fas fa-tachometer-alt',
+ bg: 'var(--bg)',
+ },
+ version,
+ vueVersion,
+ url,
+ stats: null,
+ meta: null,
+ serverInfo: null,
+ usersComparedToThePrevDay: null,
+ notesComparedToThePrevDay: null,
+ fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
+ fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
+ queueStatsConnection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats', {}).then(stats => {
+ this.stats = stats;
+
+ os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1];
+ });
+
+ os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1];
+ });
+ });
+
+ os.api('admin/server-info', {}).then(serverInfo => {
+ this.serverInfo = serverInfo;
+ });
+
+ this.$nextTick(() => {
+ this.queueStatsConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.queueStatsConnection.dispose();
+ },
+
+ methods: {
+ async showInstanceInfo(q) {
+ let instance = q;
+ if (typeof q === 'string') {
+ instance = await os.api('federation/show-instance', {
+ host: q
+ });
+ }
+ os.popup(MkInstanceInfo, {
+ instance: instance
+ }, {}, 'closed');
+ },
+
+ bytes,
+
+ number,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.edbbcaef {
+ .cfcdecdf {
+ display: grid;
+ grid-gap: 8px;
+ grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+
+ > .number {
+ padding: 12px 16px;
+
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
+
+ > .value {
+ font-weight: bold;
+ font-size: 1.2em;
+
+ > .diff {
+ font-size: 0.8em;
+ }
+ }
+ }
+ }
+
+ > .charts {
+ margin: var(--margin);
+ }
+
+ > .queue {
+ margin: var(--margin);
+ display: flex;
+
+ > .deliver,
+ > .inbox {
+ flex: 1;
+ width: 50%;
+
+ &:not(:first-child) {
+ margin-left: var(--margin);
+ }
+ }
+ }
+
+ &.max-width_740px {
+ > .queue {
+ display: block;
+
+ > .deliver,
+ > .inbox {
+ width: 100%;
+
+ &:not(:first-child) {
+ margin-top: var(--margin);
+ margin-left: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
new file mode 100644
index 0000000000..5852c6a20d
--- /dev/null
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -0,0 +1,87 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.proxyAccount }}</template>
+ <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template>
+ </FormKeyValueView>
+ <template #caption>{{ $ts.proxyAccountDescription }}</template>
+ </FormGroup>
+
+ <FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormKeyValueView,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.proxyAccount,
+ icon: 'fas fa-ghost',
+ bg: 'var(--bg)',
+ },
+ proxyAccount: null,
+ proxyAccountId: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.proxyAccountId = meta.proxyAccountId;
+ if (this.proxyAccountId) {
+ this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId });
+ }
+ },
+
+ chooseProxyAccount() {
+ os.selectUser().then(user => {
+ this.proxyAccount = user;
+ this.proxyAccountId = user.id;
+ this.save();
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ proxyAccountId: this.proxyAccountId,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
new file mode 100644
index 0000000000..136fb63bb6
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -0,0 +1,102 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><slot name="title"></slot></div>
+ <div class="_debobigegoPanel 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="">
+ <MkQueueChart :domain="domain" :connection="connection"/>
+ </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;">{{ $ts.noJobs }}</span>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue';
+import number from '@/filters/number';
+import MkQueueChart from '@/components/queue-chart.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkQueueChart
+ },
+
+ props: {
+ domain: {
+ type: String,
+ required: true,
+ },
+ connection: {
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const activeSincePrevTick = ref(0);
+ const active = ref(0);
+ const waiting = ref(0);
+ const delayed = ref(0);
+ const jobs = ref([]);
+
+ onMounted(() => {
+ os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
+ jobs.value = result;
+ });
+
+ const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ waiting.value = stats[props.domain].waiting;
+ delayed.value = stats[props.domain].delayed;
+ };
+
+ props.connection.on('stats', onStats);
+
+ onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ });
+ });
+
+ return {
+ jobs,
+ activeSincePrevTick,
+ active,
+ waiting,
+ delayed,
+ number,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+ > .status {
+ padding: 16px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .jobs {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ max-height: 180px;
+ overflow: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
new file mode 100644
index 0000000000..896298840c
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+ <XQueue :connection="connection" domain="inbox">
+ <template #title>In</template>
+ </XQueue>
+ <XQueue :connection="connection" domain="deliver">
+ <template #title>Out</template>
+ </XQueue>
+ <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XQueue from './queue.chart.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ XQueue,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.jobQueue,
+ icon: 'fas fa-clipboard-list',
+ bg: 'var(--bg)',
+ },
+ connection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ this.$nextTick(() => {
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ title: this.$ts.clearQueueConfirmTitle,
+ text: this.$ts.clearQueueConfirmText,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/queue/clear', {});
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
new file mode 100644
index 0000000000..fd0ce97d57
--- /dev/null
+++ b/packages/client/src/pages/admin/relays.vue
@@ -0,0 +1,99 @@
+<template>
+<FormBase class="relaycxt">
+ <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
+
+ <div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <div>{{ relay.inbox }}</div>
+ <div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ MkInput,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.relays,
+ icon: 'fas fa-globe',
+ bg: 'var(--bg)',
+ },
+ relays: [],
+ inbox: '',
+ }
+ },
+
+ created() {
+ this.refresh();
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async addRelay() {
+ const { canceled, result: inbox } = await os.dialog({
+ title: this.$ts.addRelay,
+ input: {
+ placeholder: this.$ts.inboxUrl
+ }
+ });
+ if (canceled) return;
+ os.api('admin/relays/add', {
+ inbox
+ }).then((relay: any) => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ remove(inbox: string) {
+ os.api('admin/relays/remove', {
+ inbox
+ }).then(() => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ refresh() {
+ os.api('admin/relays/list').then((relays: any) => {
+ this.relays = relays;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
new file mode 100644
index 0000000000..ad53ec4fcf
--- /dev/null
+++ b/packages/client/src/pages/admin/security.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/bot-protection">
+ <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}
+ <template #suffix v-if="enableHcaptcha">hCaptcha</template>
+ <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template>
+ <template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template>
+ </FormLink>
+
+ <FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
+
+ <FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormLink,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.security,
+ icon: 'fas fa-lock',
+ bg: 'var(--bg)',
+ },
+ enableHcaptcha: false,
+ enableRecaptcha: false,
+ enableRegistration: false,
+ emailRequiredForSignup: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableHcaptcha = meta.enableHcaptcha;
+ this.enableRecaptcha = meta.enableRecaptcha;
+ this.enableRegistration = !meta.disableRegistration;
+ this.emailRequiredForSignup = meta.emailRequiredForSignup;
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ disableRegistration: !this.enableRegistration,
+ emailRequiredForSignup: this.emailRequiredForSignup,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/service-worker.vue b/packages/client/src/pages/admin/service-worker.vue
new file mode 100644
index 0000000000..9e91d6d64f
--- /dev/null
+++ b/packages/client/src/pages/admin/service-worker.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableServiceWorker">
+ {{ $ts.enableServiceworker }}
+ <template #desc>{{ $ts.serviceworkerInfo }}</template>
+ </FormSwitch>
+
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Public key
+ </FormInput>
+
+ <FormInput v-model="swPrivateKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Private key
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'ServiceWorker',
+ icon: 'fas fa-bolt',
+ bg: 'var(--bg)',
+ },
+ enableServiceWorker: false,
+ swPublicKey: null,
+ swPrivateKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableServiceWorker = meta.enableServiceWorker;
+ this.swPublicKey = meta.swPublickey;
+ this.swPrivateKey = meta.swPrivateKey;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableServiceWorker: this.enableServiceWorker,
+ swPublicKey: this.swPublicKey,
+ swPrivateKey: this.swPrivateKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
new file mode 100644
index 0000000000..66aa3e21db
--- /dev/null
+++ b/packages/client/src/pages/admin/settings.vue
@@ -0,0 +1,151 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormInput v-model="name">
+ <span>{{ $ts.instanceName }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description">
+ <span>{{ $ts.instanceDescription }}</span>
+ </FormTextarea>
+
+ <FormInput v-model="iconUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.iconUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="bannerUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.bannerUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="backgroundImageUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.backgroundImageUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="tosUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.tosUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerName">
+ <span>{{ $ts.maintainerName }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerEmail" type="email">
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <span>{{ $ts.maintainerEmail }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="pinnedUsers">
+ <span>{{ $ts.pinnedUsers }}</span>
+ <template #desc>{{ $ts.pinnedUsersDescription }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="maxNoteTextLength" type="number">
+ <template #prefix><i class="fas fa-pencil-alt"></i></template>
+ <span>{{ $ts.maxNoteTextLength }}</span>
+ </FormInput>
+
+ <FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
+ <FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
+ <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.general,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ },
+ name: null,
+ description: null,
+ tosUrl: null as string | null,
+ maintainerName: null,
+ maintainerEmail: null,
+ iconUrl: null,
+ bannerUrl: null,
+ backgroundImageUrl: null,
+ maxNoteTextLength: 0,
+ enableLocalTimeline: false,
+ enableGlobalTimeline: false,
+ pinnedUsers: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.name = meta.name;
+ this.description = meta.description;
+ this.tosUrl = meta.tosUrl;
+ this.iconUrl = meta.iconUrl;
+ this.bannerUrl = meta.bannerUrl;
+ this.backgroundImageUrl = meta.backgroundImageUrl;
+ this.maintainerName = meta.maintainerName;
+ this.maintainerEmail = meta.maintainerEmail;
+ this.maxNoteTextLength = meta.maxNoteTextLength;
+ this.enableLocalTimeline = !meta.disableLocalTimeline;
+ this.enableGlobalTimeline = !meta.disableGlobalTimeline;
+ this.pinnedUsers = meta.pinnedUsers.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ name: this.name,
+ description: this.description,
+ tosUrl: this.tosUrl,
+ iconUrl: this.iconUrl,
+ bannerUrl: this.bannerUrl,
+ backgroundImageUrl: this.backgroundImageUrl,
+ maintainerName: this.maintainerName,
+ maintainerEmail: this.maintainerEmail,
+ maxNoteTextLength: this.maxNoteTextLength,
+ disableLocalTimeline: !this.enableLocalTimeline,
+ disableGlobalTimeline: !this.enableGlobalTimeline,
+ pinnedUsers: this.pinnedUsers.split('\n'),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
new file mode 100644
index 0000000000..f4a2ffa6d2
--- /dev/null
+++ b/packages/client/src/pages/admin/users.vue
@@ -0,0 +1,254 @@
+<template>
+<div class="lknzcolw">
+ <div class="users">
+ <div class="inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="available">{{ $ts.normal }}</option>
+ <option value="admin">{{ $ts.administrator }}</option>
+ <option value="moderator">{{ $ts.moderator }}</option>
+ <option value="silenced">{{ $ts.silence }}</option>
+ <option value="suspended">{{ $ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <div class="inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </div>
+
+ <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
+ <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
+ <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <header>
+ <MkUserName class="name" :user="user"/>
+ <span class="acct">@{{ acct(user) }}</span>
+ <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
+ <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
+ <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
+ <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
+ </header>
+ <div>
+ <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+ </div>
+ <div>
+ <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+ </div>
+ </div>
+ </button>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.users,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-search',
+ text: this.$ts.search,
+ handler: this.searchUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addUser,
+ handler: this.addUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: this.$ts.lookup,
+ handler: this.lookupUser
+ }],
+ },
+ sort: '+createdAt',
+ state: 'all',
+ origin: 'local',
+ searchUsername: '',
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/show-users',
+ limit: 10,
+ params: () => ({
+ sort: this.sort,
+ state: this.state,
+ origin: this.origin,
+ username: this.searchUsername,
+ hostname: this.searchHost,
+ }),
+ offsetMode: true
+ },
+ }
+ },
+
+ watch: {
+ sort() {
+ this.$refs.users.reload();
+ },
+ state() {
+ this.$refs.users.reload();
+ },
+ origin() {
+ this.$refs.users.reload();
+ },
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ lookupUser,
+
+ searchUser() {
+ os.selectUser().then(user => {
+ this.show(user);
+ });
+ },
+
+ async addUser() {
+ const { canceled: canceled1, result: username } = await os.dialog({
+ title: this.$ts.username,
+ input: true
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await os.dialog({
+ title: this.$ts.password,
+ input: { type: 'password' }
+ });
+ if (canceled2) return;
+
+ os.apiWithDialog('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ this.$refs.users.reload();
+ });
+ },
+
+ show(user) {
+ os.pageWindow(`/user-info/${user.id}`);
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lknzcolw {
+ > .users {
+ margin: var(--margin);
+
+ > .inputs {
+ display: flex;
+ margin-bottom: 16px;
+
+ > * {
+ margin-right: 16px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ > .users {
+ margin-top: var(--margin);
+
+ > .user {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+ padding: 16px;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .avatar {
+ width: 60px;
+ height: 60px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+
+ > header {
+ > .name {
+ font-weight: bold;
+ }
+
+ > .acct {
+ margin-left: 8px;
+ opacity: 0.7;
+ }
+
+ > .staff {
+ margin-left: 0.5em;
+ color: var(--badge);
+ }
+
+ > .punished {
+ margin-left: 0.5em;
+ color: #4dabf7;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/advanced-theme-editor.vue b/packages/client/src/pages/advanced-theme-editor.vue
new file mode 100644
index 0000000000..eebfc21b3f
--- /dev/null
+++ b/packages/client/src/pages/advanced-theme-editor.vue
@@ -0,0 +1,352 @@
+<template>
+<div class="t9makv94">
+ <section class="_section">
+ <div class="_content">
+ <details>
+ <summary>{{ $ts.import }}</summary>
+ <MkTextarea v-model="themeToImport">
+ {{ $ts._theme.importInfo }}
+ </MkTextarea>
+ <MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton>
+ </details>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_content _card _gap">
+ <div class="_content">
+ <MkInput v-model="name" required><span>{{ $ts.name }}</span></MkInput>
+ <MkInput v-model="author" required><span>{{ $ts.author }}</span></MkInput>
+ <MkTextarea v-model="description"><span>{{ $ts.description }}</span></MkTextarea>
+ <div class="_inputs">
+ <div v-text="$ts._theme.base" />
+ <MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio>
+ <MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio>
+ </div>
+ </div>
+ </div>
+ <div class="_content _card _gap">
+ <div class="list-view _content">
+ <div class="item" v-for="([ k, v ], i) in theme" :key="k">
+ <div class="_inputs">
+ <div>
+ {{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }}
+ <button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" />
+ </div>
+ <div>
+ <div class="type" @click="chooseType($event, i)">
+ {{ getTypeOf(v) }} <i class="fas fa-chevron-down"></i>
+ </div>
+ <!-- default -->
+ <div v-if="v === null" v-text="baseProps[k]" class="default-value" />
+ <!-- color -->
+ <div v-else-if="typeof v === 'string'" class="color">
+ <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/>
+ <MkInput class="select" :value="v" @update:modelValue="colorChanged($event, i)"/>
+ </div>
+ <!-- ref const -->
+ <MkInput v-else-if="v.type === 'refConst'" v-model="v.key">
+ <template #prefix>$</template>
+ <span>{{ $ts.name }}</span>
+ </MkInput>
+ <!-- ref props -->
+ <MkSelect class="select" v-else-if="v.type === 'refProp'" v-model="v.key">
+ <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+ </MkSelect>
+ <!-- func -->
+ <template v-else-if="v.type === 'func'">
+ <MkSelect class="select" v-model="v.name">
+ <template #label>{{ $ts._theme.funcKind }}</template>
+ <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option>
+ </MkSelect>
+ <MkInput type="number" v-model="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput>
+ <MkSelect class="select" v-model="v.value">
+ <template #label>{{ $ts._theme.basedProp }}</template>
+ <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+ </MkSelect>
+ </template>
+ <!-- CSS -->
+ <MkInput v-else-if="v.type === 'css'" v-model="v.value">
+ <span>CSS</span>
+ </MkInput>
+ </div>
+ </div>
+ </div>
+ <MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <details class="_content">
+ <summary>{{ $ts.sample }}</summary>
+ <MkSample/>
+ </details>
+ </section>
+ <section class="_section">
+ <div class="_content">
+ <MkButton inline @click="preview">{{ $ts.preview }}</MkButton>
+ <MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import { toUnicode } from 'punycode/';
+
+import MkRadio from '@/components/form/radio.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSample from '@/components/sample.vue';
+
+import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor';
+import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkRadio,
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSelect,
+ MkSample,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.themeEditor,
+ icon: 'fas fa-palette',
+ },
+ theme: [] as ThemeViewModel,
+ name: '',
+ description: '',
+ baseTheme: 'light' as 'dark' | 'light',
+ author: `@${this.$i.username}@${toUnicode(host)}`,
+ themeToImport: '',
+ changed: false,
+ lightTheme, darkTheme, themeProps,
+ }
+ },
+
+ computed: {
+ baseProps() {
+ return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props;
+ },
+ },
+
+ beforeUnmount() {
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ async beforeRouteLeave(to, from, next) {
+ if (this.changed && !(await this.confirm())) {
+ next(false);
+ } else {
+ next();
+ }
+ },
+
+ mounted() {
+ this.init();
+ window.addEventListener('beforeunload', this.beforeunload);
+ const changed = () => this.changed = true;
+ this.$watch('name', changed);
+ this.$watch('description', changed);
+ this.$watch('baseTheme', changed);
+ this.$watch('author', changed);
+ this.$watch('theme', changed);
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async confirm(): Promise<boolean> {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ });
+ return !canceled;
+ },
+
+ init() {
+ const t: ThemeViewModel = [];
+ for (const key of themeProps) {
+ t.push([ key, null ]);
+ }
+ this.theme = t;
+ },
+
+ async del(i: number) {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }),
+ });
+ if (canceled) return;
+ Vue.delete(this.theme, i);
+ },
+
+ async addConst() {
+ const { canceled, result } = await os.dialog({
+ title: this.$ts._theme.inputConstantName,
+ input: true
+ });
+ if (canceled) return;
+ this.theme.push([ '$' + result, '#000000']);
+ },
+
+ save() {
+ const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+ addTheme(theme);
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: theme.name })
+ });
+ this.changed = false;
+ },
+
+ preview() {
+ const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+ try {
+ applyTheme(theme, false);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ }
+ },
+
+ async importTheme() {
+ if (this.changed && (!await this.confirm())) return;
+
+ try {
+ const theme = JSON5.parse(this.themeToImport) as Theme;
+ if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid);
+
+ this.name = theme.name;
+ this.description = theme.desc || '';
+ this.author = theme.author;
+ this.baseTheme = theme.base || 'light';
+ this.theme = convertToViewModel(theme);
+ this.themeToImport = '';
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ }
+ },
+
+ colorChanged(color: string, i: number) {
+ this.theme[i] = [this.theme[i][0], color];
+ },
+
+ getTypeOf(v: ThemeValue) {
+ return v === null
+ ? this.$ts._theme.defaultValue
+ : typeof v === 'string'
+ ? this.$ts._theme.color
+ : this.$t('_theme.' + v.type);
+ },
+
+ async chooseType(e: MouseEvent, i: number) {
+ const newValue = await this.showTypeMenu(e);
+ this.theme[i] = [ this.theme[i][0], newValue ];
+ },
+
+ showTypeMenu(e: MouseEvent) {
+ return new Promise<ThemeValue>((resolve) => {
+ os.popupMenu([{
+ text: this.$ts._theme.defaultValue,
+ action: () => resolve(null),
+ }, {
+ text: this.$ts._theme.color,
+ action: () => resolve('#000000'),
+ }, {
+ text: this.$ts._theme.func,
+ action: () => resolve({
+ type: 'func', name: 'alpha', arg: 1, value: 'accent'
+ }),
+ }, {
+ text: this.$ts._theme.refProp,
+ action: () => resolve({
+ type: 'refProp', key: 'accent',
+ }),
+ }, {
+ text: this.$ts._theme.refConst,
+ action: () => resolve({
+ type: 'refConst', key: '',
+ }),
+ }, {
+ text: 'CSS',
+ action: () => resolve({
+ type: 'css', value: '',
+ }),
+ }], e.currentTarget || e.target);
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.t9makv94 {
+ > ._section {
+ > ._content {
+ > .list-view {
+ > .item {
+ min-height: 48px;
+ word-break: break-all;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .select {
+ margin: 24px 0;
+ }
+
+ .type {
+ cursor: pointer;
+ }
+
+ .default-value {
+ opacity: 0.6;
+ pointer-events: none;
+ user-select: none;
+ }
+
+ .color {
+ > input {
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ }
+
+ > div {
+ margin-left: 8px;
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
new file mode 100644
index 0000000000..946b368733
--- /dev/null
+++ b/packages/client/src/pages/announcements.vue
@@ -0,0 +1,74 @@
+<template>
+<MkSpacer :content-max="800">
+ <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content">
+ <section class="_card announcement" v-for="(announcement, i) in items" :key="announcement.id">
+ <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 class="_footer" v-if="$i && !announcement.isRead">
+ <MkButton @click="read(items, announcement, i)" primary><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+ </div>
+ </section>
+ </MkPagination>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.announcements,
+ icon: 'fas fa-broadcast-tower',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ // TODO: ใ“ใ‚ŒใฏๅฎŸ่ณช็š„ใซ่ฆชใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใ‹ใ‚‰ๅญใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใฎใƒ—ใƒญใƒ‘ใƒ†ใ‚ฃใ‚’ๅค‰ๆ›ดใ—ใฆใ‚‹ใฎใงใชใ‚“ใจใ‹ใ—ใŸใ„
+ read(items, announcement, i) {
+ items[i] = {
+ ...announcement,
+ isRead: true,
+ };
+ os.api('i/read-announcement', { announcementId: announcement.id });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ruryvtyk {
+ > .announcement {
+ &:not(:last-child) {
+ margin-bottom: var(--margin);
+ }
+
+ > ._content {
+ > img {
+ display: block;
+ max-height: 300px;
+ max-width: 100%;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
new file mode 100644
index 0000000000..f7f6990fa8
--- /dev/null
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="tqmomfks" v-hotkey.global="keymap" v-size="{ min: [800] }">
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="antennaId"
+ src="antenna"
+ :antenna="antennaId"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XTimeline,
+ },
+
+ props: {
+ antennaId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ antenna: null,
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => this.antenna ? {
+ title: this.antenna.name,
+ icon: 'fas fa-satellite',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }, {
+ icon: 'fas fa-cog',
+ text: this.$ts.settings,
+ handler: this.settings
+ }],
+ } : null),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+ },
+
+ watch: {
+ antennaId: {
+ async handler() {
+ this.antenna = await os.api('antennas/show', {
+ antennaId: this.antennaId
+ });
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ settings() {
+ this.$router.push(`/my/antennas/${this.antennaId}`);
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</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;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
new file mode 100644
index 0000000000..48495df3c2
--- /dev/null
+++ b/packages/client/src/pages/api-console.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="_root">
+ <div class="_block" style="padding: 24px;">
+ <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()" class="">
+ <template #label>Endpoint</template>
+ </MkInput>
+ <MkTextarea v-model="body" code>
+ <template #label>Params (JSON or JSON5)</template>
+ </MkTextarea>
+ <MkSwitch v-model="withCredential">
+ With credential
+ </MkSwitch>
+ <MkButton primary full @click="send" :disabled="sending">
+ <template v-if="sending"><MkEllipsis/></template>
+ <template v-else><i class="fas fa-paper-plane"></i> Send</template>
+ </MkButton>
+ </div>
+ <div v-if="res" class="_block" style="padding: 24px;">
+ <MkTextarea v-model="res" code readonly tall>
+ <template #label>Response</template>
+ </MkTextarea>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import MkButton from '@/components/ui/button.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 * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInput, MkTextarea, MkSwitch,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'API console',
+ icon: 'fas fa-terminal'
+ },
+
+ endpoint: '',
+ body: '{}',
+ res: null,
+ sending: false,
+ endpoints: [],
+ withCredential: true,
+
+ };
+ },
+
+ created() {
+ os.api('endpoints').then(endpoints => {
+ this.endpoints = endpoints;
+ });
+ },
+
+ methods: {
+ send() {
+ this.sending = true;
+ os.api(this.endpoint, JSON5.parse(this.body)).then(res => {
+ this.sending = false;
+ this.res = JSON5.stringify(res, null, 2);
+ }, err => {
+ this.sending = false;
+ this.res = JSON5.stringify(err, null, 2);
+ });
+ },
+
+ onEndpointChange() {
+ os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => {
+ const body = {};
+ for (const p of endpoint.params) {
+ body[p.name] =
+ p.type === 'String' ? '' :
+ p.type === 'Number' ? 0 :
+ p.type === 'Boolean' ? false :
+ p.type === 'Array' ? [] :
+ p.type === 'Object' ? {} :
+ null;
+ }
+ this.body = JSON5.stringify(body, null, 2);
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue
new file mode 100644
index 0000000000..8b2adc3e07
--- /dev/null
+++ b/packages/client/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 @click="cancel" inline>{{ $ts.cancel }}</MkButton>
+ <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: ['session'],
+ computed: {
+ name(): string {
+ const el = document.createElement('div');
+ el.textContent = this.app.name
+ return el.innerHTML;
+ },
+ app(): any {
+ return this.session.app;
+ }
+ },
+ methods: {
+ cancel() {
+ os.api('auth/deny', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('denied');
+ });
+ },
+
+ accept() {
+ os.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('accepted');
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
new file mode 100644
index 0000000000..522bd4cdf8
--- /dev/null
+++ b/packages/client/src/pages/auth.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="" v-if="$i && fetching">
+ <MkLoading/>
+</div>
+<div v-else-if="$i">
+ <XForm
+ class="form"
+ ref="form"
+ v-if="state == 'waiting'"
+ :session="session"
+ @denied="state = 'denied'"
+ @accepted="accepted"
+ />
+ <div class="denied" v-if="state == 'denied'">
+ <h1>{{ $ts._auth.denied }}</h1>
+ </div>
+ <div class="accepted" v-if="state == 'accepted'">
+ <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$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 class="error" v-if="state == 'fetch-session-error'">
+ <p>{{ $ts.somethingHappened }}</p>
+ </div>
+</div>
+<div class="signin" v-else>
+ <MkSignin @login="onLogin"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XForm from './auth.form.vue';
+import MkSignin from '@/components/signin.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ XForm,
+ MkSignin,
+ },
+ data() {
+ return {
+ state: null,
+ session: null,
+ fetching: true
+ };
+ },
+ computed: {
+ token(): string {
+ return this.$route.params.token;
+ }
+ },
+ mounted() {
+ if (!this.$i) return;
+
+ // Fetch session
+ os.api('auth/session/show', {
+ token: this.token
+ }).then(session => {
+ this.session = session;
+ this.fetching = false;
+
+ // ๆ—ขใซ้€ฃๆบใ—ใฆใ„ใŸๅ ดๅˆ
+ if (this.session.app.isAuthorized) {
+ os.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.accepted();
+ });
+ } else {
+ this.state = 'waiting';
+ }
+ }).catch(error => {
+ this.state = 'fetch-session-error';
+ this.fetching = false;
+ });
+ },
+ methods: {
+ accepted() {
+ this.state = 'accepted';
+ if (this.session.app.callbackUrl) {
+ location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
+ }
+ }, onLogin(res) {
+ login(res.i);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
new file mode 100644
index 0000000000..e2cf8b9f00
--- /dev/null
+++ b/packages/client/src/pages/channel-editor.vue
@@ -0,0 +1,129 @@
+<template>
+<div>
+ <div class="_section">
+ <div class="_content">
+ <MkInput v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="description">
+ <template #label>{{ $ts.description }}</template>
+ </MkTextarea>
+
+ <div class="banner">
+ <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
+ <div v-else-if="bannerUrl">
+ <img :src="bannerUrl" style="width: 100%;"/>
+ <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="_footer">
+ <MkButton @click="save()" primary><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTextarea, MkButton, MkInput,
+ },
+
+ props: {
+ channelId: {
+ type: String,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.channelId ? {
+ title: this.$ts._channel.edit,
+ icon: 'fas fa-satellite-dish',
+ } : {
+ title: this.$ts._channel.create,
+ icon: 'fas fa-satellite-dish',
+ }),
+ channel: null,
+ name: null,
+ description: null,
+ bannerUrl: null,
+ bannerId: null,
+ };
+ },
+
+ watch: {
+ async bannerId() {
+ if (this.bannerId == null) {
+ this.bannerUrl = null;
+ } else {
+ this.bannerUrl = (await os.api('drive/files/show', {
+ fileId: this.bannerId,
+ })).url;
+ }
+ },
+ },
+
+ async created() {
+ if (this.channelId) {
+ this.channel = await os.api('channels/show', {
+ channelId: this.channelId,
+ });
+
+ this.name = this.channel.name;
+ this.description = this.channel.description;
+ this.bannerId = this.channel.bannerId;
+ this.bannerUrl = this.channel.bannerUrl;
+ }
+ },
+
+ methods: {
+ save() {
+ const params = {
+ name: this.name,
+ description: this.description,
+ bannerId: this.bannerId,
+ };
+
+ if (this.channelId) {
+ params.channelId = this.channelId;
+ os.api('channels/update', params)
+ .then(channel => {
+ os.success();
+ });
+ } else {
+ os.api('channels/create', params)
+ .then(channel => {
+ os.success();
+ this.$router.push(`/channels/${channel.id}`);
+ });
+ }
+ },
+
+ setBannerImage(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ this.bannerId = file.id;
+ });
+ },
+
+ removeBannerImage() {
+ this.bannerId = null;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
new file mode 100644
index 0000000000..f9a9ca29e9
--- /dev/null
+++ b/packages/client/src/pages/channel.vue
@@ -0,0 +1,186 @@
+<template>
+<div v-if="channel" class="_section">
+ <div class="wpgynlbz _content _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="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ <div class="hideOverlay" v-if="!showBanner">
+ </div>
+ <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+ <div class="status">
+ <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+ <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$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 class="description" v-if="channel.description">
+ <Mfm :text="channel.description" :is-note="false" :i="$i"/>
+ </div>
+ </div>
+
+ <XPostForm :channel="channel" class="post-form _content _panel _gap" fixed v-if="$i"/>
+
+ <XTimeline class="_content _gap" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XPostForm from '@/components/post-form.vue';
+import XTimeline from '@/components/timeline.vue';
+import XChannelFollowButton from '@/components/channel-follow-button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ XPostForm,
+ XTimeline,
+ XChannelFollowButton
+ },
+
+ props: {
+ channelId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.channel ? {
+ title: this.channel.name,
+ icon: 'fas fa-satellite-dish',
+ } : null),
+ channel: null,
+ showBanner: true,
+ pagination: {
+ endpoint: 'channels/timeline',
+ limit: 10,
+ params: () => ({
+ channelId: this.channelId,
+ })
+ },
+ };
+ },
+
+ watch: {
+ channelId: {
+ async handler() {
+ this.channel = await os.api('channels/show', {
+ channelId: this.channelId,
+ });
+ },
+ immediate: true
+ }
+ },
+
+ created() {
+
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.wpgynlbz {
+ position: relative;
+
+ > .subscribe {
+ position: absolute;
+ z-index: 1;
+ top: 16px;
+ left: 16px;
+ }
+
+ > .toggle {
+ position: absolute;
+ z-index: 2;
+ top: 8px;
+ right: 8px;
+ font-size: 1.2em;
+ width: 48px;
+ height: 48px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 100%;
+
+ > i {
+ vertical-align: middle;
+ }
+ }
+
+ > .banner {
+ position: relative;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+
+ > .status {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+ }
+ }
+
+ > .description {
+ padding: 16px;
+ }
+
+ > .hideOverlay {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(16px));
+ backdrop-filter: var(--blur, blur(16px));
+ background: rgba(0, 0, 0, 0.3);
+ }
+
+ &.hide {
+ > .subscribe {
+ display: none;
+ }
+
+ > .toggle {
+ top: 0;
+ right: 0;
+ height: 100%;
+ background: transparent;
+ }
+
+ > .banner {
+ height: 42px;
+ filter: blur(8px);
+
+ > * {
+ display: none;
+ }
+ }
+
+ > .description {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
new file mode 100644
index 0000000000..09e136ac00
--- /dev/null
+++ b/packages/client/src/pages/channels.vue
@@ -0,0 +1,77 @@
+<template>
+<div>
+ <div class="_section" style="padding: 0;" v-if="$i">
+ <MkTab class="_content" v-model="tab">
+ <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._channel.featured }}</option>
+ <option value="following"><i class="fas fa-heart"></i> {{ $ts._channel.following }}</option>
+ <option value="owned"><i class="fas fa-edit"></i> {{ $ts._channel.owned }}</option>
+ </MkTab>
+ </div>
+
+ <div class="_section">
+ <div class="_content grwlizim featured" v-if="tab === 'featured'">
+ <MkPagination :pagination="featuredPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="_content grwlizim following" v-if="tab === 'following'">
+ <MkPagination :pagination="followingPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="_content grwlizim owned" v-if="tab === 'owned'">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination :pagination="ownedPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkChannelPreview from '@/components/channel-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkChannelPreview, MkPagination, MkButton, MkTab
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.channel,
+ icon: 'fas fa-satellite-dish',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ tab: 'featured',
+ featuredPagination: {
+ endpoint: 'channels/featured',
+ noPaging: true,
+ },
+ followingPagination: {
+ endpoint: 'channels/followed',
+ limit: 5,
+ },
+ ownedPagination: {
+ endpoint: 'channels/owned',
+ limit: 5,
+ },
+ };
+ },
+ methods: {
+ create() {
+ this.$router.push(`/channels/new`);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
new file mode 100644
index 0000000000..510a73ce68
--- /dev/null
+++ b/packages/client/src/pages/clip.vue
@@ -0,0 +1,154 @@
+<template>
+<div v-if="clip" class="_section">
+ <div class="okzinsic _content _panel _gap">
+ <div class="description" v-if="clip.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 class="_content _gap" :pagination="pagination" :detail="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XPostForm from '@/components/post-form.vue';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ XPostForm,
+ XNotes,
+ },
+
+ props: {
+ clipId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.clip ? {
+ title: this.clip.name,
+ icon: 'fas fa-paperclip',
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu
+ }
+ } : null),
+ clip: null,
+ pagination: {
+ endpoint: 'clips/notes',
+ limit: 10,
+ params: () => ({
+ clipId: this.clipId,
+ })
+ },
+ };
+ },
+
+ computed: {
+ isOwned(): boolean {
+ return this.$i && this.clip && (this.$i.id === this.clip.userId);
+ }
+ },
+
+ watch: {
+ clipId: {
+ async handler() {
+ this.clip = await os.api('clips/show', {
+ clipId: this.clipId,
+ });
+ },
+ immediate: true
+ }
+ },
+
+ created() {
+
+ },
+
+ methods: {
+ menu(ev) {
+ os.popupMenu([this.isOwned ? {
+ icon: 'fas fa-pencil-alt',
+ text: this.$ts.edit,
+ action: async () => {
+ const { canceled, result } = await os.form(this.clip.name, {
+ name: {
+ type: 'string',
+ label: this.$ts.name,
+ default: this.clip.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description,
+ default: this.clip.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: this.clip.isPublic
+ }
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('clips/update', {
+ clipId: this.clip.id,
+ ...result
+ });
+ }
+ } : undefined, this.isOwned ? {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: async () => {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('deleteAreYouSure', { x: this.clip.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('clips/delete', {
+ clipId: this.clip.id,
+ });
+ }
+ } : undefined], ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.okzinsic {
+ position: relative;
+
+ > .description {
+ padding: 16px;
+ }
+
+ > .user {
+ $height: 32px;
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ line-height: $height;
+
+ > .avatar {
+ width: $height;
+ height: $height;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
new file mode 100644
index 0000000000..5d7d3b2f5a
--- /dev/null
+++ b/packages/client/src/pages/drive.vue
@@ -0,0 +1,28 @@
+<template>
+<div>
+ <XDrive ref="drive" @cd="x => folder = x"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XDrive from '@/components/drive.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XDrive
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: computed(() => this.folder ? this.folder.name : this.$ts.drive),
+ icon: 'fas fa-cloud',
+ },
+ folder: null,
+ };
+ },
+});
+</script>
diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue
new file mode 100644
index 0000000000..327cbce7e8
--- /dev/null
+++ b/packages/client/src/pages/emojis.category.vue
@@ -0,0 +1,135 @@
+<template>
+<div class="driuhtrh">
+ <div class="query">
+ <MkInput v-model="q" class="" :placeholder="$ts.search">
+ <template #prefix><i class="fas fa-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 class="emojis" v-if="searchEmojis">
+ <template #header>{{ $ts.searchResult }}</template>
+ <div class="zuvgdzyt">
+ <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
+ </div>
+ </MkFolder>
+
+ <MkFolder class="emojis" v-for="category in customEmojiCategories" :key="category">
+ <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 MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { emojiCategories, emojiTags } from '@/instance';
+import XEmoji from './emojis.emoji.vue';
+
+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(e => e.name.includes(this.q) || e.aliases.includes(this.q));
+ } else {
+ this.searchEmojis = this.customEmojis.filter(e => (e.name.includes(this.q) || e.aliases.includes(this.q)) && [...this.selectedTags].every(t => e.aliases.includes(t)));
+ }
+ },
+
+ toggleTag(tag) {
+ if (this.selectedTags.has(tag)) {
+ this.selectedTags.delete(tag);
+ } else {
+ this.selectedTags.add(tag);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.driuhtrh {
+ background: var(--bg);
+
+ > .query {
+ background: var(--bg);
+ padding: 16px;
+
+ > .tags {
+ > .tag {
+ display: inline-block;
+ margin: 8px 8px 0 0;
+ padding: 4px 8px;
+ font-size: 0.9em;
+ background: var(--accentedBg);
+ border-radius: 5px;
+
+ &.active {
+ background: var(--accent);
+ color: var(--fgOnAccent);
+ }
+ }
+ }
+ }
+
+ > .emojis {
+ --x-padding: 0 16px;
+
+ .zuvgdzyt {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: 0 var(--margin) var(--margin) var(--margin);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/emojis.emoji.vue b/packages/client/src/pages/emojis.emoji.vue
new file mode 100644
index 0000000000..4ca7c15742
--- /dev/null
+++ b/packages/client/src/pages/emojis.emoji.vue
@@ -0,0 +1,94 @@
+<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">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import VanillaTilt from 'vanilla-tilt';
+
+export default defineComponent({
+ props: {
+ emoji: {
+ type: Object,
+ required: true,
+ }
+ },
+
+ mounted() {
+ if (this.$store.animation) {
+ VanillaTilt.init(this.$el, {
+ reverse: true,
+ gyroscope: false,
+ scale: 1.1,
+ speed: 500,
+ });
+ }
+ },
+
+ methods: {
+ menu(ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + this.emoji.name + ':',
+ }, {
+ text: this.$ts.copy,
+ icon: 'fas fa-copy',
+ action: () => {
+ copyToClipboard(`:${this.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;
+ transform-style: preserve-3d;
+ transform: perspective(1000px);
+
+ &:hover {
+ border-color: var(--accent);
+ }
+
+ > .img {
+ width: 42px;
+ height: 42px;
+ transform: translateZ(20px);
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ transform: translateZ(10px);
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 0.9em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue
new file mode 100644
index 0000000000..ae06fa7938
--- /dev/null
+++ b/packages/client/src/pages/emojis.vue
@@ -0,0 +1,36 @@
+<template>
+<div :class="$style.root">
+ <XCategory v-if="tab === 'category'"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, computed } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import XCategory from './emojis.category.vue';
+
+export default defineComponent({
+ components: {
+ XCategory,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.customEmojis,
+ icon: 'fas fa-laugh',
+ bg: 'var(--bg)',
+ })),
+ tab: 'category',
+ }
+ },
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+</style>
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
new file mode 100644
index 0000000000..7b1fcd0910
--- /dev/null
+++ b/packages/client/src/pages/explore.vue
@@ -0,0 +1,261 @@
+<template>
+<div>
+ <MkSpacer :content-max="1200">
+ <div class="lznhrdub">
+ <div v-if="tab === 'local'">
+ <div class="localfedi7 _block _isolated" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
+ <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
+ <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
+ </div>
+
+ <template v-if="tag == null">
+ <MkFolder class="_gap" persist-key="explore-pinned-users">
+ <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template>
+ <XUserList :pagination="pinnedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-popular-users">
+ <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-updated-users">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-registered-users">
+ <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsers"/>
+ </MkFolder>
+ </template>
+ </div>
+ <div v-else-if="tab === 'remote'">
+ <div class="localfedi7 _block _isolated" v-if="tag == null" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }">
+ <header><span>{{ $ts.exploreFediverse }}</span></header>
+ </div>
+
+ <MkFolder :foldable="true" :expanded="false" ref="tags" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
+
+ <div class="vxjfqztj">
+ <MkA v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</MkA>
+ <MkA v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</MkA>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-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 fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsersF"/>
+ </MkFolder>
+ </template>
+ </div>
+ <div v-else-if="tab === 'search'">
+ <div class="_isolated">
+ <MkInput v-model="searchQuery" :debounce="true" type="search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.searchUser }}</template>
+ </MkInput>
+ <MkRadios v-model="searchOrigin">
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ <option value="both">{{ $ts.all }}</option>
+ </MkRadios>
+ </div>
+
+ <XUserList v-if="searchQuery" class="_gap" :pagination="searchPagination" ref="search"/>
+ </div>
+ </div>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XUserList from '@/components/user-list.vue';
+import MkFolder from '@/components/ui/folder.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 * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserList,
+ MkFolder,
+ MkInput,
+ MkRadios,
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.explore,
+ icon: 'fas fa-hashtag',
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.tab === 'local',
+ title: this.$ts.local,
+ onClick: () => { this.tab = 'local'; },
+ }, {
+ active: this.tab === 'remote',
+ title: this.$ts.remote,
+ onClick: () => { this.tab = 'remote'; },
+ }, {
+ active: this.tab === 'search',
+ title: this.$ts.search,
+ onClick: () => { this.tab = 'search'; },
+ },]
+ })),
+ tab: 'local',
+ pinnedUsers: { endpoint: 'pinned-users' },
+ popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'local',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ state: 'alive',
+ sort: '+createdAt',
+ } },
+ popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'remote',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+createdAt',
+ } },
+ searchPagination: {
+ endpoint: 'users/search',
+ limit: 10,
+ params: computed(() => (this.searchQuery && this.searchQuery !== '') ? {
+ query: this.searchQuery,
+ origin: this.searchOrigin,
+ } : null)
+ },
+ tagsLocal: [],
+ tagsRemote: [],
+ stats: null,
+ searchQuery: null,
+ searchOrigin: 'combined',
+ num: number,
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ tagUsers(): any {
+ return {
+ endpoint: 'hashtags/users',
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
+ }
+ };
+ },
+ },
+
+ watch: {
+ tag() {
+ if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
+ },
+ },
+
+ created() {
+ os.api('hashtags/list', {
+ sort: '+attachedLocalUsers',
+ attachedToLocalUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsLocal = tags;
+ });
+ os.api('hashtags/list', {
+ sort: '+attachedRemoteUsers',
+ attachedToRemoteUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsRemote = tags;
+ });
+ os.api('stats').then(stats => {
+ this.stats = stats;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.localfedi7 {
+ color: #fff;
+ padding: 16px;
+ height: 80px;
+ background-position: 50%;
+ background-size: cover;
+ margin-bottom: var(--margin);
+
+ > * {
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > span {
+ display: inline-block;
+ padding: 6px 8px;
+ background: rgba(0, 0, 0, 0.7);
+ }
+ }
+
+ > header {
+ font-size: 20px;
+ font-weight: bold;
+ }
+
+ > div {
+ font-size: 14px;
+ opacity: 0.8;
+ }
+}
+
+.vxjfqztj {
+ > * {
+ margin-right: 16px;
+
+ &.local {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
new file mode 100644
index 0000000000..980d59835f
--- /dev/null
+++ b/packages/client/src/pages/favorites.vue
@@ -0,0 +1,60 @@
+<template>
+<div class="jmelgwjh">
+ <div class="body">
+ <XNotes class="notes" :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.favorites,
+ icon: 'fas fa-star',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/favorites',
+ limit: 10,
+ params: () => ({
+ })
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jmelgwjh {
+ background: var(--bg);
+
+ > .body {
+ box-sizing: border-box;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue
new file mode 100644
index 0000000000..f5edf25594
--- /dev/null
+++ b/packages/client/src/pages/featured.vue
@@ -0,0 +1,43 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.featured,
+ icon: 'fas fa-fire-alt',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/featured',
+ limit: 10,
+ offsetMode: true,
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue
new file mode 100644
index 0000000000..1bd5da58e3
--- /dev/null
+++ b/packages/client/src/pages/federation.vue
@@ -0,0 +1,265 @@
+<template>
+<div class="taeiyria">
+ <div class="query">
+ <MkInput v-model="host" :debounce="true" class="">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <div class="_inputSplit">
+ <MkSelect v-model="state">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="federating">{{ $ts.federating }}</option>
+ <option value="subscribing">{{ $ts.subscribing }}</option>
+ <option value="publishing">{{ $ts.publishing }}</option>
+ <option value="suspended">{{ $ts.suspended }}</option>
+ <option value="blocked">{{ $ts.blocked }}</option>
+ <option value="notResponding">{{ $ts.notResponding }}</option>
+ </MkSelect>
+ <MkSelect v-model="sort">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+ <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+ <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+ <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+ <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+ <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
+ <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
+ <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
+ <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.descendingOrder }})</option>
+ <option value="-driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.ascendingOrder }})</option>
+ </MkSelect>
+ </div>
+ </div>
+
+ <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state">
+ <div class="dqokceoi">
+ <MkA class="instance" v-for="instance in items" :key="instance.id" :to="`/instance-info/${instance.host}`">
+ <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
+ <div class="table">
+ <div class="cell">
+ <div class="key">{{ $ts.registeredAt }}</div>
+ <div class="value"><MkTime :time="instance.caughtAt"/></div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.software }}</div>
+ <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.version }}</div>
+ <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.users }}</div>
+ <div class="value">{{ instance.usersCount }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.notes }}</div>
+ <div class="value">{{ instance.notesCount }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.sent }}</div>
+ <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.received }}</div>
+ <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ <div class="footer">
+ <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
+ <span class="pubSub">
+ <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span>
+ <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span>
+ <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span>
+ <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span>
+ </span>
+ <span class="right">
+ <span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
+ <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
+ </span>
+ </div>
+ </MkA>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.federation,
+ icon: 'fas fa-globe',
+ bg: 'var(--bg)',
+ },
+ host: '',
+ state: 'federating',
+ sort: '+pubSub',
+ pagination: {
+ endpoint: 'federation/instances',
+ limit: 10,
+ offsetMode: true,
+ params: () => ({
+ sort: this.sort,
+ host: this.host != '' ? this.host : null,
+ ...(
+ this.state === 'federating' ? { federating: true } :
+ this.state === 'subscribing' ? { subscribing: true } :
+ this.state === 'publishing' ? { publishing: true } :
+ this.state === 'suspended' ? { suspended: true } :
+ this.state === 'blocked' ? { blocked: true } :
+ this.state === 'notResponding' ? { notResponding: true } :
+ {})
+ })
+ },
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.instances.reload();
+ },
+ state() {
+ this.$refs.instances.reload();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ getStatus(instance) {
+ if (instance.isSuspended) return 'suspended';
+ if (instance.isNotResponding) return 'error';
+ return 'alive';
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.taeiyria {
+ > .query {
+ background: var(--bg);
+ padding: 16px;
+ }
+}
+
+.dqokceoi {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
+ padding: 16px;
+
+ > .instance {
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .host {
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > img {
+ width: 18px;
+ height: 18px;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+ }
+
+ > .table {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
+ grid-gap: 6px;
+ margin: 6px 0;
+ font-size: 70%;
+
+ > .cell {
+ > .key, > .value {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .key {
+ opacity: 0.7;
+ }
+
+ > .value {
+ }
+ }
+ }
+
+ > .footer {
+ display: flex;
+ align-items: center;
+ font-size: 0.9em;
+
+ > .status {
+ &.suspended {
+ opacity: 0.5;
+ }
+
+ &.error {
+ color: var(--error);
+ }
+
+ &.alive {
+ color: var(--success);
+ }
+ }
+
+ > .pubSub {
+ margin-left: 8px;
+ }
+
+ > .right {
+ margin-left: auto;
+
+ > .latestStatus {
+ border: solid 1px var(--divider);
+ border-radius: 4px;
+ margin: 0 8px;
+ padding: 0 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
new file mode 100644
index 0000000000..d8967dc9d9
--- /dev/null
+++ b/packages/client/src/pages/follow-requests.vue
@@ -0,0 +1,153 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" class="mk-follow-requests" ref="list">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noFollowRequests }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div class="user _panel" v-for="req in items" :key="req.id">
+ <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
+ <div class="body">
+ <div class="name">
+ <MkA class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></MkA>
+ <p class="acct">@{{ acct(req.follower) }}</p>
+ </div>
+ <div class="description" v-if="req.follower.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="fas fa-check"></i></button>
+ <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { userPage, acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.followRequests,
+ icon: 'fas fa-user-clock',
+ },
+ pagination: {
+ endpoint: 'following/requests/list',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ accept(user) {
+ os.api('following/requests/accept', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ },
+ reject(user) {
+ os.api('following/requests/reject', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ },
+ userPage,
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-follow-requests {
+ > .user {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ display: flex;
+ width: calc(100% - 54px);
+ position: relative;
+
+ > .name {
+ width: 45%;
+
+ @media (max-width: 500px) {
+ width: 100%;
+ }
+
+ > .name,
+ > .acct {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0;
+ }
+
+ > .name {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ > .acct {
+ font-size: 15px;
+ line-height: 16px;
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ width: 55%;
+ line-height: 42px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ font-size: 14px;
+ padding-right: 40px;
+ padding-left: 8px;
+ box-sizing: border-box;
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+ }
+
+ > .actions {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ margin: auto 0;
+
+ > button {
+ padding: 12px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
new file mode 100644
index 0000000000..e8eaad73bf
--- /dev/null
+++ b/packages/client/src/pages/follow.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="mk-follow-page">
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as Acct from 'misskey-js/built/acct';
+
+export default defineComponent({
+ created() {
+ const acct = new URL(location.href).searchParams.get('acct');
+ if (acct == null) return;
+
+ let promise;
+
+ if (acct.startsWith('https://')) {
+ promise = os.api('ap/show', {
+ uri: acct
+ });
+ promise.then(res => {
+ if (res.type == 'User') {
+ this.follow(res.object);
+ } else if (res.type === 'Note') {
+ this.$router.push(`/notes/${res.object.id}`);
+ } else {
+ os.dialog({
+ type: 'error',
+ text: 'Not a user'
+ }).then(() => {
+ window.close();
+ });
+ }
+ });
+ } else {
+ promise = os.api('users/show', Acct.parse(acct));
+ promise.then(user => {
+ this.follow(user);
+ });
+ }
+
+ os.promiseDialog(promise, null, null, this.$ts.fetchingAsApObject);
+ },
+
+ methods: {
+ async follow(user) {
+ const { canceled } = await os.dialog({
+ type: 'question',
+ text: this.$t('followConfirm', { name: user.name || user.username }),
+ showCancelButton: true
+ });
+
+ if (canceled) {
+ window.close();
+ return;
+ }
+
+ os.apiWithDialog('following/create', {
+ userId: user.id
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
new file mode 100644
index 0000000000..1ee3a9390b
--- /dev/null
+++ b/packages/client/src/pages/gallery/edit.vue
@@ -0,0 +1,168 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormInput v-model="title">
+ <span>{{ $ts.title }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description" :max="500">
+ <span>{{ $ts.description }}</span>
+ </FormTextarea>
+
+ <FormGroup>
+ <div v-for="file in files" :key="file.id" class="_debobigegoItem _debobigegoPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
+ <div class="name">{{ file.name }}</div>
+ <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button>
+ </div>
+ <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton>
+ </FormGroup>
+
+ <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch>
+
+ <FormButton v-if="postId" @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ <FormButton v-else @click="save" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton>
+
+ <FormButton v-if="postId" @click="del" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormTuple from '@/components/debobigego/tuple.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ FormSuspense,
+ },
+
+ props: {
+ postId: {
+ type: String,
+ required: false,
+ default: null,
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.postId ? {
+ title: this.$ts.edit,
+ icon: 'fas fa-pencil-alt'
+ } : {
+ title: this.$ts.postToGallery,
+ icon: 'fas fa-pencil-alt'
+ }),
+ init: null,
+ files: [],
+ description: null,
+ title: null,
+ isSensitive: false,
+ }
+ },
+
+ watch: {
+ postId: {
+ handler() {
+ this.init = () => this.postId ? os.api('gallery/posts/show', {
+ postId: this.postId
+ }).then(post => {
+ this.files = post.files;
+ this.title = post.title;
+ this.description = post.description;
+ this.isSensitive = post.isSensitive;
+ }) : Promise.resolve(null);
+ },
+ immediate: true,
+ }
+ },
+
+ methods: {
+ selectFile(e) {
+ selectFile(e.currentTarget || e.target, null, true).then(files => {
+ this.files = this.files.concat(files);
+ });
+ },
+
+ remove(file) {
+ this.files = this.files.filter(f => f.id !== file.id);
+ },
+
+ async save() {
+ if (this.postId) {
+ await os.apiWithDialog('gallery/posts/update', {
+ postId: this.postId,
+ title: this.title,
+ description: this.description,
+ fileIds: this.files.map(file => file.id),
+ isSensitive: this.isSensitive,
+ });
+ this.$router.push(`/gallery/${this.postId}`);
+ } else {
+ const post = await os.apiWithDialog('gallery/posts/create', {
+ title: this.title,
+ description: this.description,
+ fileIds: this.files.map(file => file.id),
+ isSensitive: this.isSensitive,
+ });
+ this.$router.push(`/gallery/${post.id}`);
+ }
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteConfirm,
+ showCancelButton: true
+ });
+ if (canceled) return;
+ await os.apiWithDialog('gallery/posts/delete', {
+ postId: this.postId,
+ });
+ this.$router.push(`/gallery`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wqugxsfx {
+ height: 200px;
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ position: relative;
+
+ > .name {
+ position: absolute;
+ top: 8px;
+ left: 9px;
+ padding: 8px;
+ background: var(--panel);
+ }
+
+ > .remove {
+ position: absolute;
+ top: 8px;
+ right: 9px;
+ padding: 8px;
+ background: var(--panel);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
new file mode 100644
index 0000000000..dfcd59349e
--- /dev/null
+++ b/packages/client/src/pages/gallery/index.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="xprsixdl _root">
+ <MkTab v-model="tab" v-if="$i">
+ <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
+ <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
+ <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
+ </MkTab>
+
+ <div v-if="tab === 'explore'">
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
+ <MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ </div>
+ <div v-else-if="tab === 'liked'">
+ <MkPagination :pagination="likedPostsPagination" #default="{items}">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/>
+ </div>
+ </MkPagination>
+ </div>
+ <div v-else-if="tab === 'my'">
+ <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
+ <MkPagination :pagination="myPostsPagination" #default="{items}">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XUserList from '@/components/user-list.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkInput from '@/components/form/input.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserList,
+ MkFolder,
+ MkInput,
+ MkButton,
+ MkTab,
+ MkPagination,
+ MkGalleryPostPreview,
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.gallery,
+ icon: 'fas fa-icons'
+ },
+ tab: 'explore',
+ recentPostsPagination: {
+ endpoint: 'gallery/posts',
+ limit: 6,
+ },
+ popularPostsPagination: {
+ endpoint: 'gallery/featured',
+ limit: 5,
+ },
+ myPostsPagination: {
+ endpoint: 'i/gallery/posts',
+ limit: 5,
+ },
+ likedPostsPagination: {
+ endpoint: 'i/gallery/likes',
+ limit: 5,
+ },
+ tags: [],
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ tagUsers(): any {
+ return {
+ endpoint: 'hashtags/users',
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
+ }
+ };
+ },
+ },
+
+ watch: {
+ tag() {
+ if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
+ },
+ },
+
+ created() {
+
+ },
+
+ methods: {
+
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xprsixdl {
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.vfpdbgtk {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: 0 var(--margin);
+
+ > .post {
+
+ }
+}
+</style>
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
new file mode 100644
index 0000000000..255954def0
--- /dev/null
+++ b/packages/client/src/pages/gallery/post.vue
@@ -0,0 +1,282 @@
+<template>
+<div class="_root">
+ <transition name="fade" mode="out-in">
+ <div v-if="post" class="rkxwuolj">
+ <div class="files">
+ <div class="file" v-for="file in post.files" :key="file.id">
+ <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="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
+ </div>
+ <div class="actions">
+ <div class="like">
+ <MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton>
+ <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button v-if="$i && $i.id === post.user.id" class="_button" @click="edit" v-tooltip="$ts.edit" v-click-anime><i class="fas fa-pencil-alt fa-fw"></i></button>
+ <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button>
+ <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-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="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="otherPostsPagination" #default="{items}">
+ <div class="sdrarzaf">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkContainer>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import MkContainer from '@/components/ui/container.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import { url } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ ImgWithBlurhash,
+ MkPagination,
+ MkGalleryPostPreview,
+ MkButton,
+ MkFollowButton,
+ },
+ props: {
+ postId: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.post ? {
+ title: this.post.title,
+ avatar: this.post.user,
+ path: `/gallery/${this.post.id}`,
+ share: {
+ title: this.post.title,
+ text: this.post.description,
+ },
+ actions: [{
+ icon: 'fas fa-pencil-alt',
+ text: this.$ts.edit,
+ handler: this.edit
+ }]
+ } : null),
+ otherPostsPagination: {
+ endpoint: 'users/gallery/posts',
+ limit: 6,
+ params: () => ({
+ userId: this.post.user.id
+ })
+ },
+ post: null,
+ error: null,
+ };
+ },
+
+ watch: {
+ postId: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ this.post = null;
+ os.api('gallery/posts/show', {
+ postId: this.postId
+ }).then(post => {
+ this.post = post;
+ }).catch(e => {
+ this.error = e;
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.post.title,
+ text: this.post.description,
+ url: `${url}/gallery/${this.post.id}`
+ });
+ },
+
+ shareWithNote() {
+ os.post({
+ initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
+ });
+ },
+
+ like() {
+ os.apiWithDialog('gallery/posts/like', {
+ postId: this.postId,
+ }).then(() => {
+ this.post.isLiked = true;
+ this.post.likedCount++;
+ });
+ },
+
+ async unlike() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('gallery/posts/unlike', {
+ postId: this.postId,
+ }).then(() => {
+ this.post.isLiked = false;
+ this.post.likedCount--;
+ });
+ },
+
+ edit() {
+ this.$router.push(`/gallery/${this.post.id}/edit`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.rkxwuolj {
+ > .files {
+ > .file {
+ > img {
+ display: block;
+ max-width: 100%;
+ max-height: 500px;
+ margin: 0 auto;
+ }
+
+ & + .file {
+ margin-top: 16px;
+ }
+ }
+ }
+
+ > .body {
+ padding: 32px;
+
+ > .title {
+ font-weight: bold;
+ font-size: 1.2em;
+ margin-bottom: 16px;
+ }
+
+ > .info {
+ margin-top: 16px;
+ font-size: 90%;
+ opacity: 0.7;
+ }
+
+ > .actions {
+ display: flex;
+ align-items: center;
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+
+ > .like {
+ > .button {
+ --accent: rgb(241 97 132);
+ --X8: rgb(241 92 128);
+ --buttonBg: rgb(216 71 106 / 5%);
+ --buttonHoverBg: rgb(216 71 106 / 10%);
+ color: #ff002f;
+
+ ::v-deep(.count) {
+ margin-left: 0.5em;
+ }
+ }
+ }
+
+ > .other {
+ margin-left: auto;
+
+ > button {
+ padding: 8px;
+ margin: 0 8px;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+ }
+ }
+
+ > .user {
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+ display: flex;
+ align-items: center;
+
+ > .avatar {
+ width: 52px;
+ height: 52px;
+ }
+
+ > .name {
+ margin: 0 0 0 12px;
+ font-size: 90%;
+ }
+
+ > .koudoku {
+ margin-left: auto;
+ }
+ }
+ }
+}
+
+.sdrarzaf {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .post {
+
+ }
+}
+</style>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
new file mode 100644
index 0000000000..586d9d7e52
--- /dev/null
+++ b/packages/client/src/pages/instance-info.vue
@@ -0,0 +1,238 @@
+<template>
+<FormBase>
+ <FormGroup v-if="instance">
+ <template #label>{{ instance.host }}</template>
+ <FormGroup>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel fnfelxur">
+ <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
+ </div>
+ </div>
+ <FormKeyValueView>
+ <template #key>Name</template>
+ <template #value><span class="_monospace">{{ instance.name || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormButton v-if="$i.isAdmin || $i.isModerator" @click="info" primary>{{ $ts.settings }}</FormButton>
+
+ <FormTextarea readonly :value="instance.description">
+ <span>{{ $ts.description }}</span>
+ </FormTextarea>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.software }}</template>
+ <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.version }}</template>
+ <template #value><span class="_monospace">{{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value><span class="_monospace">{{ instance.maintainerName || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.contact }}</template>
+ <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestRequestSentAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestStatus }}</template>
+ <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestRequestReceivedAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Open Registrations</template>
+ <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.statistics }}</div>
+ <div class="_debobigegoPanel cmhjzshl">
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+ <option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+ <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+ <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+ <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+ <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+ <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+ <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+ <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+ <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ <div class="chart">
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
+ </div>
+ </div>
+ </div>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.registeredAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormObjectView tall :value="instance">
+ <span>Raw</span>
+ </FormObjectView>
+ <FormGroup>
+ <template #label>Well-known resources</template>
+ <FormLink :to="`https://${host}/.well-known/host-meta`" external>host-meta</FormLink>
+ <FormLink :to="`https://${host}/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+ <FormLink :to="`https://${host}/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+ <FormLink :to="`https://${host}/robots.txt`" external>robots.txt</FormLink>
+ <FormLink :to="`https://${host}/manifest.json`" external>manifest.json</FormLink>
+ </FormGroup>
+ <FormSuspense :p="dnsPromiseFactory" v-slot="{ result: dns }">
+ <FormGroup>
+ <template #label>DNS</template>
+ <FormKeyValueView v-for="record in dns.a" :key="record">
+ <template #key>A</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.aaaa" :key="record">
+ <template #key>AAAA</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.cname" :key="record">
+ <template #key>CNAME</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.txt">
+ <template #key>TXT</template>
+ <template #value><span class="_monospace">{{ record[0] }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import MkChart from '@/components/chart.vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import MkSelect from '@/components/form/select.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import MkInstanceInfo from '@/pages/admin/instance.vue';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ MkSelect,
+ MkChart,
+ },
+
+ props: {
+ host: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceInfo,
+ icon: 'fas fa-info-circle',
+ actions: [{
+ text: `https://${this.host}`,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(`https://${this.host}`, '_blank');
+ }
+ }],
+ },
+ instance: null,
+ dnsPromiseFactory: () => os.api('federation/dns', {
+ host: this.host
+ }),
+ chartSrc: 'instance-requests',
+ chartSpan: 'hour',
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ methods: {
+ number,
+ bytes,
+
+ async fetch() {
+ this.instance = await os.api('federation/show-instance', {
+ host: this.host
+ });
+ },
+
+ info() {
+ os.popup(MkInstanceInfo, {
+ instance: this.instance
+ }, {}, 'closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fnfelxur {
+ padding: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ }
+}
+
+.cmhjzshl {
+ > .selects {
+ display: flex;
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue
new file mode 100644
index 0000000000..cd9c6a8fdf
--- /dev/null
+++ b/packages/client/src/pages/mentions.vue
@@ -0,0 +1,42 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.mentions,
+ icon: 'fas fa-at',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue
new file mode 100644
index 0000000000..9fde0bc7d5
--- /dev/null
+++ b/packages/client/src/pages/messages.vue
@@ -0,0 +1,45 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.directNotes,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ params: () => ({
+ visibility: 'specified'
+ })
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
new file mode 100644
index 0000000000..896c3927ce
--- /dev/null
+++ b/packages/client/src/pages/messaging/index.vue
@@ -0,0 +1,307 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="yweeujhr" v-size="{ max: [400] }">
+ <MkButton @click="start" primary class="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
+
+ <div class="history" v-if="messages.length > 0">
+ <MkA v-for="(message, i) in messages"
+ 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"
+ :key="message.id"
+ v-anim="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 class="me" v-if="isMe(message)">{{ $ts.you }}:</span>{{ message.text }}</p>
+ </div>
+ </div>
+ </MkA>
+ </div>
+ <div class="_fullinfo" v-if="!fetching && messages.length == 0">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noHistory }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkButton from '@/components/ui/button.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.messaging,
+ icon: 'fas fa-comments',
+ bg: 'var(--bg)',
+ },
+ fetching: true,
+ moreFetching: false,
+ messages: [],
+ connection: null,
+ };
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('messagingIndex'));
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.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());
+ this.messages = messages;
+ this.fetching = false;
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ getAcct: Acct.toString,
+
+ isMe(message) {
+ return message.userId == this.$i.id;
+ },
+
+ onMessage(message) {
+ if (message.recipientId) {
+ this.messages = this.messages.filter(m => !(
+ (m.recipientId == message.recipientId && m.userId == message.userId) ||
+ (m.recipientId == message.userId && m.userId == message.recipientId)));
+
+ this.messages.unshift(message);
+ } else if (message.groupId) {
+ this.messages = this.messages.filter(m => m.groupId !== message.groupId);
+ this.messages.unshift(message);
+ }
+ },
+
+ onRead(ids) {
+ for (const id of ids) {
+ const found = this.messages.find(m => m.id == id);
+ if (found) {
+ if (found.recipientId) {
+ found.isRead = true;
+ } else if (found.groupId) {
+ found.reads.push(this.$i.id);
+ }
+ }
+ }
+ },
+
+ start(ev) {
+ os.popupMenu([{
+ text: this.$ts.messagingWithUser,
+ icon: 'fas fa-user',
+ action: () => { this.startUser() }
+ }, {
+ text: this.$ts.messagingWithGroup,
+ icon: 'fas fa-users',
+ action: () => { this.startGroup() }
+ }], ev.currentTarget || ev.target);
+ },
+
+ async startUser() {
+ os.selectUser().then(user => {
+ this.$router.push(`/my/messaging/${Acct.toString(user)}`);
+ });
+ },
+
+ async 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.dialog({
+ type: 'warning',
+ title: this.$ts.youHaveNoGroups,
+ text: this.$ts.joinOrCreateGroup,
+ });
+ return;
+ }
+ const { canceled, result: group } = await os.dialog({
+ type: null,
+ title: this.$ts.group,
+ select: {
+ items: groups1.concat(groups2).map(group => ({
+ value: group, text: group.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.$router.push(`/my/messaging/group/${group.id}`);
+ },
+
+ acct
+ }
+});
+</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;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
new file mode 100644
index 0000000000..aafed2632d
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.form.vue
@@ -0,0 +1,348 @@
+<template>
+<div class="pemppnzi _block"
+ @dragover.stop="onDragover"
+ @drop.stop="onDrop"
+>
+ <textarea
+ v-model="text"
+ ref="text"
+ @keypress="onKeypress"
+ @compositionupdate="onCompositionUpdate"
+ @paste="onPaste"
+ :placeholder="$ts.inputMessageHere"
+ ></textarea>
+ <div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
+ <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$ts.send">
+ <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+ </button>
+ <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
+ <input ref="file" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import * as autosize from 'autosize';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { Autocomplete } from '@/scripts/autocomplete';
+import { throttle } from 'throttle-debounce';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ requird: false,
+ },
+ group: {
+ type: Object,
+ requird: false,
+ },
+ },
+ data() {
+ return {
+ text: null,
+ file: null,
+ sending: false,
+ typing: throttle(3000, () => {
+ os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
+ }),
+ };
+ },
+ computed: {
+ draftKey(): string {
+ return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
+ },
+ canSend(): boolean {
+ return (this.text != null && this.text != '') || this.file != null;
+ },
+ room(): any {
+ return this.$parent;
+ }
+ },
+ watch: {
+ text() {
+ this.saveDraft();
+ },
+ file() {
+ this.saveDraft();
+ }
+ },
+ mounted() {
+ autosize(this.$refs.text);
+
+ // TODO: detach when unmount
+ new Autocomplete(this.$refs.text, this, { model: 'text' });
+
+ // ๆ›ธใใ‹ใ‘ใฎๆŠ•็จฟใ‚’ๅพฉๅ…ƒ
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
+ if (draft) {
+ this.text = draft.data.text;
+ this.file = draft.data.file;
+ }
+ },
+ methods: {
+ async onPaste(e: ClipboardEvent) {
+ const data = e.clipboardData;
+ const items = data.items;
+
+ if (items.length == 1) {
+ if (items[0].kind == 'file') {
+ const file = items[0].getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
+ const name = this.$store.state.pasteDialog
+ ? await os.dialog({
+ title: this.$ts.enterFileName,
+ input: {
+ default: formatted
+ },
+ allowEmpty: false
+ }).then(({ canceled, result }) => canceled ? false : result)
+ : formatted;
+ if (name) this.upload(file, name);
+ }
+ } else {
+ if (items[0].kind == 'file') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ }
+ }
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+ },
+
+ onDrop(e): void {
+ // ใƒ•ใ‚กใ‚คใƒซใ ใฃใŸใ‚‰
+ if (e.dataTransfer.files.length == 1) {
+ e.preventDefault();
+ this.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ e.preventDefault();
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ return;
+ }
+
+ //#region ใƒ‰ใƒฉใ‚คใƒ–ใฎใƒ•ใ‚กใ‚คใƒซ
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ this.file = JSON.parse(driveFile);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ onKeypress(e) {
+ this.typing();
+ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
+ this.send();
+ }
+ },
+
+ onCompositionUpdate() {
+ this.typing();
+ },
+
+ chooseFile(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
+ this.file = file;
+ });
+ },
+
+ onChangeFile() {
+ this.upload((this.$refs.file as any).files[0]);
+ },
+
+ upload(file: File, name?: string) {
+ os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+ this.file = res;
+ });
+ },
+
+ send() {
+ this.sending = true;
+ os.api('messaging/messages/create', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ text: this.text ? this.text : undefined,
+ fileId: this.file ? this.file.id : undefined
+ }).then(message => {
+ this.clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ this.sending = false;
+ });
+ },
+
+ clear() {
+ this.text = '';
+ this.file = null;
+ this.deleteDraft();
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ data[this.draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ file: this.file
+ }
+ }
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ delete data[this.draftKey];
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+
+ async insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.pemppnzi {
+ position: relative;
+
+ > textarea {
+ cursor: auto;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ 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);
+ }
+
+ > .file {
+ padding: 8px;
+ color: #444;
+ background: #eee;
+ cursor: pointer;
+ }
+
+ > .send {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ transition: color 0.1s ease;
+ color: var(--accent);
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
+ }
+
+ .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;
+ }
+ }
+ }
+
+ ._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;
+ }
+ }
+
+ input[type=file] {
+ display: none;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue
new file mode 100644
index 0000000000..432d11add8
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.message.vue
@@ -0,0 +1,350 @@
+<template>
+<div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }">
+ <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
+ <div class="content">
+ <div class="balloon" :class="{ noText: message.text == null }" >
+ <button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del">
+ <img src="/client-assets/remove.png" alt="Delete"/>
+ </button>
+ <div class="content" v-if="!message.isDeleted">
+ <Mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$i"/>
+ <div class="file" v-if="message.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 class="content" v-else>
+ <p class="is-deleted">{{ $ts.deleted }}</p>
+ </div>
+ </div>
+ <div></div>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/>
+ <footer>
+ <template v-if="isGroup">
+ <span class="read" v-if="message.reads.length > 0">{{ $ts.messageRead }} {{ message.reads.length }}</span>
+ </template>
+ <template v-else>
+ <span class="read" v-if="isMe && message.isRead">{{ $ts.messageRead }}</span>
+ </template>
+ <MkTime :time="message.createdAt"/>
+ <template v-if="message.is_edited"><i class="fas fa-pencil-alt"></i></template>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as mfm from 'mfm-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import MkUrlPreview from '@/components/url-preview.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview
+ },
+ props: {
+ message: {
+ required: true
+ },
+ isGroup: {
+ required: false
+ }
+ },
+ computed: {
+ isMe(): boolean {
+ return this.message.userId === this.$i.id;
+ },
+ urls(): string[] {
+ if (this.message.text) {
+ return extractUrlFromMfm(mfm.parse(this.message.text));
+ } else {
+ return [];
+ }
+ }
+ },
+ methods: {
+ del() {
+ os.api('messaging/messages/delete', {
+ messageId: this.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);
+
+ > .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;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
new file mode 100644
index 0000000000..3a19b12762
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -0,0 +1,470 @@
+<template>
+<div class="_section"
+ @dragover.prevent.stop="onDragover"
+ @drop.prevent.stop="onDrop"
+>
+ <div class="_content mk-messaging-room">
+ <div class="body">
+ <MkLoading v-if="fetching"/>
+ <p class="empty" v-if="!fetching && messages.length == 0"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
+ <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
+ <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
+ <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
+ </button>
+ <XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed>
+ <XMessage :message="message" :is-group="group != null" :key="message.id"/>
+ </XList>
+ </div>
+ <footer>
+ <div class="typers" v-if="typers.length > 0">
+ <I18n :src="$ts.typingUsers" text-tag="span" class="users">
+ <template #users>
+ <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </div>
+ <transition name="fade">
+ <div class="new-message" v-show="showIndicator">
+ <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
+ </div>
+ </transition>
+ <XForm v-if="!fetching" :user="user" :group="group" ref="form" class="form"/>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw } from 'vue';
+import XList from '@/components/date-separated-list.vue';
+import XMessage from './messaging-room.message.vue';
+import XForm from './messaging-room.form.vue';
+import * as Acct from 'misskey-js/built/acct';
+import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import { popout } from '@/scripts/popout';
+import * as sound from '@/scripts/sound';
+import * as symbols from '@/symbols';
+
+const Component = defineComponent({
+ components: {
+ XMessage,
+ XForm,
+ XList,
+ },
+
+ inject: ['inWindow'],
+
+ props: {
+ userAcct: {
+ type: String,
+ required: false,
+ },
+ groupId: {
+ type: String,
+ required: false,
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
+ userName: this.user,
+ avatar: this.user,
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu,
+ },
+ } : {
+ title: this.group.name,
+ icon: 'fas fa-users',
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu,
+ },
+ } : null),
+ fetching: true,
+ user: null,
+ group: null,
+ fetchingMoreMessages: false,
+ messages: [],
+ existMoreMessages: false,
+ connection: null,
+ showIndicator: false,
+ timer: null,
+ typers: [],
+ ilObserver: new IntersectionObserver(
+ (entries) => entries.some((entry) => entry.isIntersecting)
+ && !this.fetching
+ && !this.fetchingMoreMessages
+ && this.existMoreMessages
+ && this.fetchMoreMessages()
+ ),
+ };
+ },
+
+ computed: {
+ form(): any {
+ return this.$refs.form;
+ }
+ },
+
+ watch: {
+ userAcct: 'fetch',
+ groupId: 'fetch',
+ },
+
+ mounted() {
+ this.fetch();
+ if (this.$store.state.enableInfiniteScroll) {
+ this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
+ }
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+
+ this.ilObserver.disconnect();
+ },
+
+ methods: {
+ async fetch() {
+ this.fetching = true;
+ if (this.userAcct) {
+ const user = await os.api('users/show', Acct.parse(this.userAcct));
+ this.user = user;
+ } else {
+ const group = await os.api('users/groups/show', { groupId: this.groupId });
+ this.group = group;
+ }
+
+ this.connection = markRaw(os.stream.useChannel('messaging', {
+ otherparty: this.user ? this.user.id : undefined,
+ group: this.group ? this.group.id : undefined,
+ }));
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.onRead);
+ this.connection.on('deleted', this.onDeleted);
+ this.connection.on('typers', typers => {
+ this.typers = typers.filter(u => u.id !== this.$i.id);
+ });
+
+ document.addEventListener('visibilitychange', this.onVisibilitychange);
+
+ this.fetchMessages().then(() => {
+ this.scrollToBottom();
+
+ // ใ‚‚ใฃใจ่ฆ‹ใ‚‹ใฎไบคๅทฎๆคœ็Ÿฅใ‚’็™บ็ซใ•ใ›ใชใ„ใŸใ‚ใซfetchใฏ
+ // ใ‚นใ‚ฏใƒญใƒผใƒซใŒ็ต‚ใ‚ใ‚‹ใพใงfalseใซใ—ใฆใŠใ
+ // scrollendใฎใ‚ˆใ†ใชใ‚คใƒ™ใƒณใƒˆใฏใชใ„ใฎใงsetTimeoutใง
+ setTimeout(() => this.fetching = false, 300);
+ });
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+
+ if (isFile || isDriveFile) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+ },
+
+ onDrop(e): void {
+ // ใƒ•ใ‚กใ‚คใƒซใ ใฃใŸใ‚‰
+ if (e.dataTransfer.files.length == 1) {
+ this.form.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ return;
+ }
+
+ //#region ใƒ‰ใƒฉใ‚คใƒ–ใฎใƒ•ใ‚กใ‚คใƒซ
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.form.file = file;
+ }
+ //#endregion
+ },
+
+ fetchMessages() {
+ return new Promise((resolve, reject) => {
+ const max = this.existMoreMessages ? 20 : 10;
+
+ os.api('messaging/messages', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ limit: max + 1,
+ untilId: this.existMoreMessages ? this.messages[0].id : undefined
+ }).then(messages => {
+ if (messages.length == max + 1) {
+ this.existMoreMessages = true;
+ messages.pop();
+ } else {
+ this.existMoreMessages = false;
+ }
+
+ this.messages.unshift.apply(this.messages, messages.reverse());
+ resolve();
+ });
+ });
+ },
+
+ fetchMoreMessages() {
+ this.fetchingMoreMessages = true;
+ this.fetchMessages().then(() => {
+ this.fetchingMoreMessages = false;
+ });
+ },
+
+ onMessage(message) {
+ sound.play('chat');
+
+ const _isBottom = isBottom(this.$el, 64);
+
+ this.messages.push(message);
+ if (message.userId != this.$i.id && !document.hidden) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+
+ if (_isBottom) {
+ // Scroll to bottom
+ this.$nextTick(() => {
+ this.scrollToBottom();
+ });
+ } else if (message.userId != this.$i.id) {
+ // Notify
+ this.notifyNewMessage();
+ }
+ },
+
+ onRead(x) {
+ if (this.user) {
+ if (!Array.isArray(x)) x = [x];
+ for (const id of x) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist] = {
+ ...this.messages[exist],
+ isRead: true,
+ };
+ }
+ }
+ } else if (this.group) {
+ for (const id of x.ids) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist] = {
+ ...this.messages[exist],
+ reads: [...this.messages[exist].reads, x.userId]
+ };
+ }
+ }
+ }
+ },
+
+ onDeleted(id) {
+ const msg = this.messages.find(m => m.id === id);
+ if (msg) {
+ this.messages = this.messages.filter(m => m.id !== msg.id);
+ }
+ },
+
+ scrollToBottom() {
+ scroll(this.$el, { top: this.$el.offsetHeight });
+ },
+
+ onIndicatorClick() {
+ this.showIndicator = false;
+ this.scrollToBottom();
+ },
+
+ notifyNewMessage() {
+ this.showIndicator = true;
+
+ onScrollBottom(this.$el, () => {
+ this.showIndicator = false;
+ });
+
+ if (this.timer) clearTimeout(this.timer);
+
+ this.timer = setTimeout(() => {
+ this.showIndicator = false;
+ }, 4000);
+ },
+
+ onVisibilitychange() {
+ if (document.hidden) return;
+ for (const message of this.messages) {
+ if (message.userId !== this.$i.id && !message.isRead) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+ }
+ },
+
+ menu(ev) {
+ const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
+
+ os.popupMenu([this.inWindow ? undefined : {
+ text: this.$ts.openInWindow,
+ icon: 'fas fa-window-maximize',
+ action: () => {
+ os.pageWindow(path);
+ this.$router.back();
+ },
+ }, this.inWindow ? undefined : {
+ text: this.$ts.popout,
+ icon: 'fas fa-external-link-alt',
+ action: () => {
+ popout(path);
+ this.$router.back();
+ },
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+
+export default Component;
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging-room {
+ > .body {
+ > .empty {
+ width: 100%;
+ margin: 0;
+ padding: 16px 8px 8px 8px;
+ text-align: center;
+ font-size: 0.8em;
+ opacity: 0.5;
+
+ i {
+ margin-right: 4px;
+ }
+ }
+
+ > .no-history {
+ display: block;
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ font-size: 0.8em;
+ color: var(--messagingRoomInfo);
+ opacity: 0.5;
+
+ i {
+ margin-right: 4px;
+ }
+ }
+
+ > .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 {
+ > ::v-deep(*) {
+ margin-bottom: 16px;
+ }
+ }
+ }
+
+ > footer {
+ width: 100%;
+ position: relative;
+
+ > .new-message {
+ position: absolute;
+ top: -48px;
+ width: 100%;
+ padding: 8px 0;
+ text-align: center;
+
+ > button {
+ display: inline-block;
+ margin: 0;
+ padding: 0 12px 0 30px;
+ line-height: 32px;
+ font-size: 12px;
+ border-radius: 16px;
+
+ > i {
+ position: absolute;
+ top: 0;
+ left: 10px;
+ line-height: 32px;
+ font-size: 16px;
+ }
+ }
+ }
+
+ > .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 {
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.1s;
+}
+
+.fade-enter-from, .fade-leave-to {
+ transition: opacity 0.5s;
+ opacity: 0;
+}
+</style>
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
new file mode 100644
index 0000000000..e9a3b6debc
--- /dev/null
+++ b/packages/client/src/pages/mfm-cheat-sheet.vue
@@ -0,0 +1,365 @@
+<template>
+<div class="mwysmxbg">
+ <div class="_isolated">{{ $ts._mfm.intro }}</div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.mention }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.hashtag }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.url }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.link }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.emoji }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.bold }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.small }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.quote }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.center }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.inlineCode }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.blockCode }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.inlineMath }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.inlineMathDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_inlineMath"/>
+ <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.search }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.flip }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.font }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.x2 }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.x3 }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.x4 }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.blur }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.jelly }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.tada }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.jump }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.bounce }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.spin }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.shake }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.twitch }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.rainbow }}</div>
+ <div class="content">
+ <p>{{ $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">{{ $ts._mfm.sparkle }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.sparkleDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_sparkle"/>
+ <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._mfm.cheatSheet,
+ icon: 'fas fa-question-circle',
+ },
+ preview_mention: '@example',
+ preview_hashtag: '#test',
+ preview_url: `https://example.com`,
+ preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`,
+ preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`,
+ preview_bold: `**${this.$ts._mfm.dummy}**`,
+ preview_small: `<small>${this.$ts._mfm.dummy}</small>`,
+ preview_center: `<center>${this.$ts._mfm.dummy}</center>`,
+ preview_inlineCode: '`<: "Hello, world!"`',
+ preview_blockCode: '```\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```',
+ preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
+ preview_quote: `> ${this.$ts._mfm.dummy}`,
+ preview_search: `${this.$ts._mfm.dummy} ๆคœ็ดข`,
+ preview_jelly: `$[jelly ๐Ÿฎ]`,
+ preview_tada: `$[tada ๐Ÿฎ]`,
+ preview_jump: `$[jump ๐Ÿฎ]`,
+ preview_bounce: `$[bounce ๐Ÿฎ]`,
+ preview_shake: `$[shake ๐Ÿฎ]`,
+ preview_twitch: `$[twitch ๐Ÿฎ]`,
+ preview_spin: `$[spin ๐Ÿฎ] $[spin.left ๐Ÿฎ] $[spin.alternate ๐Ÿฎ]\n$[spin.x ๐Ÿฎ] $[spin.x,left ๐Ÿฎ] $[spin.x,alternate ๐Ÿฎ]\n$[spin.y ๐Ÿฎ] $[spin.y,left ๐Ÿฎ] $[spin.y,alternate ๐Ÿฎ]`,
+ preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`,
+ preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`,
+ preview_x2: `$[x2 ๐Ÿฎ]`,
+ preview_x3: `$[x3 ๐Ÿฎ]`,
+ preview_x4: `$[x4 ๐Ÿฎ]`,
+ preview_blur: `$[blur ${this.$ts._mfm.dummy}]`,
+ preview_rainbow: `$[rainbow ๐Ÿฎ]`,
+ preview_sparkle: `$[sparkle ๐Ÿฎ]`,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mwysmxbg {
+ background: var(--bg);
+
+ > .section {
+ > .title {
+ position: sticky;
+ z-index: 1;
+ top: var(--stickyTop, 0px);
+ padding: 16px;
+ font-weight: bold;
+ -webkit-backdrop-filter: var(--blur, blur(10px));
+ backdrop-filter: var(--blur, blur(10px));
+ background-color: var(--X16);
+ }
+
+ > .content {
+ > p {
+ margin: 0;
+ padding: 16px;
+ }
+
+ > .preview {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
new file mode 100644
index 0000000000..6430588c46
--- /dev/null
+++ b/packages/client/src/pages/miauth.vue
@@ -0,0 +1,100 @@
+<template>
+<div v-if="$i">
+ <div class="waiting _section" v-if="state == 'waiting'">
+ <div class="_content">
+ <MkLoading/>
+ </div>
+ </div>
+ <div class="denied _section" v-if="state == 'denied'">
+ <div class="_content">
+ <p>{{ $ts._auth.denied }}</p>
+ </div>
+ </div>
+ <div class="accepted _section" v-else-if="state == 'accepted'">
+ <div class="_content">
+ <p v-if="callback">{{ $ts._auth.callback }}<MkEllipsis/></p>
+ <p v-else>{{ $ts._auth.pleaseGoBack }}</p>
+ </div>
+ </div>
+ <div class="_section" v-else>
+ <div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div>
+ <div class="_title" v-else>{{ $ts._auth.shareAccessAsk }}</div>
+ <div class="_content">
+ <p>{{ $ts._auth.permissionAsk }}</p>
+ <ul>
+ <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </div>
+ <div class="_footer">
+ <MkButton @click="deny" inline>{{ $ts.cancel }}</MkButton>
+ <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton>
+ </div>
+ </div>
+</div>
+<div class="signin" v-else>
+ <MkSignin @login="onLogin"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkSignin from '@/components/signin.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkSignin,
+ MkButton,
+ },
+ data() {
+ return {
+ state: null
+ };
+ },
+ computed: {
+ session(): string {
+ return this.$route.params.session;
+ },
+ callback(): string {
+ return this.$route.query.callback;
+ },
+ name(): string {
+ return this.$route.query.name;
+ },
+ icon(): string {
+ return this.$route.query.icon;
+ },
+ permission(): string[] {
+ return this.$route.query.permission ? this.$route.query.permission.split(',') : [];
+ },
+ },
+ methods: {
+ async accept() {
+ this.state = 'waiting';
+ await os.api('miauth/gen-token', {
+ session: this.session,
+ name: this.name,
+ iconUrl: this.icon,
+ permission: this.permission,
+ });
+
+ this.state = 'accepted';
+ if (this.callback) {
+ location.href = `${this.callback}?session=${this.session}`;
+ }
+ },
+ deny() {
+ this.state = 'denied';
+ },
+ onLogin(res) {
+ login(res.i);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
new file mode 100644
index 0000000000..173807475a
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/create.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="geegznzt">
+ <XAntenna :antenna="draft" @created="onAntennaCreated"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XAntenna from './editor.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XAntenna,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ },
+ draft: {
+ name: '',
+ src: 'all',
+ userListId: null,
+ userGroupId: null,
+ users: [],
+ keywords: [],
+ excludeKeywords: [],
+ withReplies: false,
+ caseSensitive: false,
+ withFile: false,
+ notify: false
+ },
+ };
+ },
+
+ methods: {
+ onAntennaCreated() {
+ this.$router.push('/my/antennas');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
new file mode 100644
index 0000000000..04928c81a3
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/edit.vue
@@ -0,0 +1,56 @@
+<template>
+<div class="">
+ <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XAntenna from './editor.vue';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XAntenna,
+ },
+
+ props: {
+ antennaId: {
+ type: String,
+ required: true,
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ },
+ antenna: null,
+ };
+ },
+
+ watch: {
+ antennaId: {
+ async handler() {
+ this.antenna = await os.api('antennas/show', { antennaId: this.antennaId });
+ },
+ immediate: true,
+ }
+ },
+
+ methods: {
+ onAntennaUpdated() {
+ this.$router.push('/my/antennas');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue
new file mode 100644
index 0000000000..5ad3d50486
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/editor.vue
@@ -0,0 +1,190 @@
+<template>
+<div class="shaynizk">
+ <div class="form">
+ <MkInput v-model="name" class="_formBlock">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ <MkSelect v-model="src" class="_formBlock">
+ <template #label>{{ $ts.antennaSource }}</template>
+ <option value="all">{{ $ts._antennaSources.all }}</option>
+ <option value="home">{{ $ts._antennaSources.homeTimeline }}</option>
+ <option value="users">{{ $ts._antennaSources.users }}</option>
+ <option value="list">{{ $ts._antennaSources.userList }}</option>
+ <option value="group">{{ $ts._antennaSources.userGroup }}</option>
+ </MkSelect>
+ <MkSelect v-model="userListId" v-if="src === 'list'" class="_formBlock">
+ <template #label>{{ $ts.userList }}</template>
+ <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option>
+ </MkSelect>
+ <MkSelect v-model="userGroupId" v-else-if="src === 'group'" class="_formBlock">
+ <template #label>{{ $ts.userGroup }}</template>
+ <option v-for="group in userGroups" :value="group.id" :key="group.id">{{ group.name }}</option>
+ </MkSelect>
+ <MkTextarea v-model="users" v-else-if="src === 'users'" class="_formBlock">
+ <template #label>{{ $ts.users }}</template>
+ <template #caption>{{ $ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ $ts.addUser }}</button></template>
+ </MkTextarea>
+ <MkSwitch v-model="withReplies" class="_formBlock">{{ $ts.withReplies }}</MkSwitch>
+ <MkTextarea v-model="keywords" class="_formBlock">
+ <template #label>{{ $ts.antennaKeywords }}</template>
+ <template #caption>{{ $ts.antennaKeywordsDescription }}</template>
+ </MkTextarea>
+ <MkTextarea v-model="excludeKeywords" class="_formBlock">
+ <template #label>{{ $ts.antennaExcludeKeywords }}</template>
+ <template #caption>{{ $ts.antennaKeywordsDescription }}</template>
+ </MkTextarea>
+ <MkSwitch v-model="caseSensitive" class="_formBlock">{{ $ts.caseSensitive }}</MkSwitch>
+ <MkSwitch v-model="withFile" class="_formBlock">{{ $ts.withFileAntenna }}</MkSwitch>
+ <MkSwitch v-model="notify" class="_formBlock">{{ $ts.notifyAntenna }}</MkSwitch>
+ </div>
+ <div class="actions">
+ <MkButton inline @click="saveAntenna()" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="deleteAntenna()" v-if="antenna.id != null" danger><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.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 Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInput, MkTextarea, MkSelect, MkSwitch
+ },
+
+ props: {
+ antenna: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ name: '',
+ src: '',
+ userListId: null,
+ userGroupId: null,
+ users: '',
+ keywords: '',
+ excludeKeywords: '',
+ caseSensitive: false,
+ withReplies: false,
+ withFile: false,
+ notify: false,
+ userLists: null,
+ userGroups: null,
+ };
+ },
+
+ watch: {
+ async src() {
+ if (this.src === 'list' && this.userLists === null) {
+ this.userLists = await os.api('users/lists/list');
+ }
+
+ if (this.src === 'group' && this.userGroups === null) {
+ const groups1 = await os.api('users/groups/owned');
+ const groups2 = await os.api('users/groups/joined');
+
+ this.userGroups = [...groups1, ...groups2];
+ }
+ }
+ },
+
+ created() {
+ this.name = this.antenna.name;
+ this.src = this.antenna.src;
+ this.userListId = this.antenna.userListId;
+ this.userGroupId = this.antenna.userGroupId;
+ this.users = this.antenna.users.join('\n');
+ this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n');
+ this.excludeKeywords = this.antenna.excludeKeywords.map(x => x.join(' ')).join('\n');
+ this.caseSensitive = this.antenna.caseSensitive;
+ this.withReplies = this.antenna.withReplies;
+ this.withFile = this.antenna.withFile;
+ this.notify = this.antenna.notify;
+ },
+
+ methods: {
+ async saveAntenna() {
+ if (this.antenna.id == null) {
+ await os.apiWithDialog('antennas/create', {
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ userGroupId: this.userGroupId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')),
+ excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.$emit('created');
+ } else {
+ await os.apiWithDialog('antennas/update', {
+ antennaId: this.antenna.id,
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ userGroupId: this.userGroupId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')),
+ excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.$emit('updated');
+ }
+ },
+
+ async deleteAntenna() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.antenna.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.api('antennas/delete', {
+ antennaId: this.antenna.id,
+ });
+
+ os.success();
+ this.$emit('deleted');
+ },
+
+ addUser() {
+ os.selectUser().then(user => {
+ this.users = this.users.trim();
+ this.users += '\n@' + Acct.toString(user);
+ this.users = this.users.trim();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.shaynizk {
+ > .form {
+ padding: 32px;
+ }
+
+ > .actions {
+ padding: 24px 32px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
new file mode 100644
index 0000000000..029f1949d7
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/index.vue
@@ -0,0 +1,71 @@
+<template>
+<div class="ieepwinx _section">
+ <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+
+ <div class="_content">
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <MkA class="ljoevbzj" v-for="antenna in items" :key="antenna.id" :to="`/my/antennas/${antenna.id}`">
+ <div class="name">{{ antenna.name }}</div>
+ </MkA>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ pagination: {
+ endpoint: 'antennas/list',
+ limit: 10,
+ },
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ieepwinx {
+ padding: 16px;
+
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+
+ .ljoevbzj {
+ display: block;
+ padding: 16px;
+ margin-bottom: 8px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
new file mode 100644
index 0000000000..cbcdb85fa5
--- /dev/null
+++ b/packages/client/src/pages/my-clips/index.vue
@@ -0,0 +1,104 @@
+<template>
+<div class="_section qtcaoidl">
+ <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+
+ <div class="_content">
+ <MkPagination :pagination="pagination" #default="{items}" ref="list" 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>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.clip,
+ icon: 'fas fa-paperclip',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ pagination: {
+ endpoint: 'clips/list',
+ limit: 10,
+ },
+ draft: null,
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('clips/create', result);
+ },
+
+ onClipCreated() {
+ this.$refs.list.reload();
+ this.draft = null;
+ },
+
+ onClipDeleted() {
+ this.$refs.list.reload();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qtcaoidl {
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+
+ > ._content {
+ > .list {
+ > .item {
+ display: block;
+ padding: 16px;
+
+ > .description {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue
new file mode 100644
index 0000000000..9548c374d2
--- /dev/null
+++ b/packages/client/src/pages/my-groups/group.vue
@@ -0,0 +1,184 @@
+<template>
+<div class="mk-group-page">
+ <transition name="zoom" mode="out-in">
+ <div v-if="group" class="_section">
+ <div class="_content">
+ <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton>
+ <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton>
+ <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton>
+ <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ </transition>
+
+ <transition name="zoom" mode="out-in">
+ <div v-if="group" class="_section members _gap">
+ <div class="_title">{{ $ts.members }}</div>
+ <div class="_content">
+ <div class="users">
+ <div class="user _panel" v-for="user in users" :key="user.id">
+ <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="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.group ? {
+ title: this.group.name,
+ icon: 'fas fa-users',
+ } : null),
+ group: null,
+ users: [],
+ };
+ },
+
+ watch: {
+ groupId: 'fetch',
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ os.api('users/groups/show', {
+ groupId: this.groupId
+ }).then(group => {
+ this.group = group;
+ os.api('users/show', {
+ userIds: this.group.userIds
+ }).then(users => {
+ this.users = users;
+ Progress.done();
+ });
+ });
+ },
+
+ invite() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/groups/invite', {
+ groupId: this.group.id,
+ userId: user.id
+ });
+ });
+ },
+
+ removeUser(user) {
+ os.api('users/groups/pull', {
+ groupId: this.group.id,
+ userId: user.id
+ }).then(() => {
+ this.users = this.users.filter(x => x.id !== user.id);
+ });
+ },
+
+ async renameGroup() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.groupName,
+ input: {
+ default: this.group.name
+ }
+ });
+ if (canceled) return;
+
+ await os.api('users/groups/update', {
+ groupId: this.group.id,
+ name: name
+ });
+
+ this.group.name = name;
+ },
+
+ transfer() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/groups/transfer', {
+ groupId: this.group.id,
+ userId: user.id
+ });
+ });
+ },
+
+ async deleteGroup() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.group.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('users/groups/delete', {
+ groupId: this.group.id
+ });
+ this.$router.push('/my/groups');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-group-page {
+ > .members {
+ > ._content {
+ > .users {
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ flex: 1;
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue
new file mode 100644
index 0000000000..77e7d6088e
--- /dev/null
+++ b/packages/client/src/pages/my-groups/index.vue
@@ -0,0 +1,121 @@
+<template>
+<div class="">
+ <div class="_section" style="padding: 0;">
+ <MkTab v-model="tab">
+ <option value="owned">{{ $ts.ownedGroups }}</option>
+ <option value="joined">{{ $ts.joinedGroups }}</option>
+ <option value="invites"><i class="fas fa-envelope-open-text"></i> {{ $ts.invites }}</option>
+ </MkTab>
+ </div>
+
+ <div class="_section">
+ <div class="_content" v-if="tab === 'owned'">
+ <MkButton @click="create" primary style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton>
+
+ <MkPagination :pagination="ownedPagination" #default="{items}" ref="owned">
+ <div class="_card" v-for="group in items" :key="group.id">
+ <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div>
+ <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
+ </div>
+ </MkPagination>
+ </div>
+
+ <div class="_content" v-else-if="tab === 'joined'">
+ <MkPagination :pagination="joinedPagination" #default="{items}" ref="joined">
+ <div class="_card" v-for="group in items" :key="group.id">
+ <div class="_title">{{ group.name }}</div>
+ <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
+ </div>
+ </MkPagination>
+ </div>
+
+ <div class="_content" v-else-if="tab === 'invites'">
+ <MkPagination :pagination="invitationPagination" #default="{items}" ref="invitations">
+ <div class="_card" v-for="invitation in items" :key="invitation.id">
+ <div class="_title">{{ invitation.group.name }}</div>
+ <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div>
+ <div class="_footer">
+ <MkButton @click="acceptInvite(invitation)" primary inline><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton>
+ <MkButton @click="rejectInvite(invitation)" primary inline><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton>
+ </div>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkAvatars from '@/components/avatars.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ MkContainer,
+ MkTab,
+ MkAvatars,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.groups,
+ icon: 'fas fa-users'
+ },
+ tab: 'owned',
+ ownedPagination: {
+ endpoint: 'users/groups/owned',
+ limit: 10,
+ },
+ joinedPagination: {
+ endpoint: 'users/groups/joined',
+ limit: 10,
+ },
+ invitationPagination: {
+ endpoint: 'i/user-group-invites',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.groupName,
+ input: true
+ });
+ if (canceled) return;
+ await os.api('users/groups/create', { name: name });
+ this.$refs.owned.reload();
+ os.success();
+ },
+ acceptInvite(invitation) {
+ os.api('users/groups/invitations/accept', {
+ invitationId: invitation.id
+ }).then(() => {
+ os.success();
+ this.$refs.invitations.reload();
+ this.$refs.joined.reload();
+ });
+ },
+ rejectInvite(invitation) {
+ os.api('users/groups/invitations/reject', {
+ invitationId: invitation.id
+ }).then(() => {
+ this.$refs.invitations.reload();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
new file mode 100644
index 0000000000..adb59db665
--- /dev/null
+++ b/packages/client/src/pages/my-lists/index.vue
@@ -0,0 +1,88 @@
+<template>
+<div class="qkcjvfiv">
+ <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton>
+
+ <MkPagination :pagination="pagination" #default="{items}" class="lists _content" ref="list">
+ <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>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkAvatars from '@/components/avatars.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ MkAvatars,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageLists,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ },
+ },
+ pagination: {
+ endpoint: 'users/lists/list',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.enterListName,
+ input: true
+ });
+ if (canceled) return;
+ await os.api('users/lists/create', { name: name });
+ this.$refs.list.reload();
+ os.success();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qkcjvfiv {
+ padding: 16px;
+
+ > .add {
+ margin: 0 auto var(--margin) auto;
+ }
+
+ > .lists {
+ > .list {
+ display: block;
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .name {
+ margin-bottom: 4px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
new file mode 100644
index 0000000000..f2a02cadc9
--- /dev/null
+++ b/packages/client/src/pages/my-lists/list.vue
@@ -0,0 +1,170 @@
+<template>
+<div class="mk-list-page">
+ <transition name="zoom" mode="out-in">
+ <div v-if="list" class="_section">
+ <div class="_content">
+ <MkButton inline @click="addUser()">{{ $ts.addUser }}</MkButton>
+ <MkButton inline @click="renameList()">{{ $ts.rename }}</MkButton>
+ <MkButton inline @click="deleteList()">{{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ </transition>
+
+ <transition name="zoom" mode="out-in">
+ <div v-if="list" class="_section members _gap">
+ <div class="_title">{{ $ts.members }}</div>
+ <div class="_content">
+ <div class="users">
+ <div class="user _panel" v-for="user in users" :key="user.id">
+ <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="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.list ? {
+ title: this.list.name,
+ icon: 'fas fa-list-ul',
+ } : null),
+ list: null,
+ users: [],
+ };
+ },
+
+ watch: {
+ $route: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ os.api('users/lists/show', {
+ listId: this.$route.params.list
+ }).then(list => {
+ this.list = list;
+ os.api('users/show', {
+ userIds: this.list.userIds
+ }).then(users => {
+ this.users = users;
+ Progress.done();
+ });
+ });
+ },
+
+ addUser() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/lists/push', {
+ listId: this.list.id,
+ userId: user.id
+ }).then(() => {
+ this.users.push(user);
+ });
+ });
+ },
+
+ removeUser(user) {
+ os.api('users/lists/pull', {
+ listId: this.list.id,
+ userId: user.id
+ }).then(() => {
+ this.users = this.users.filter(x => x.id !== user.id);
+ });
+ },
+
+ async renameList() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.enterListName,
+ input: {
+ default: this.list.name
+ }
+ });
+ if (canceled) return;
+
+ await os.api('users/lists/update', {
+ listId: this.list.id,
+ name: name
+ });
+
+ this.list.name = name;
+ },
+
+ async deleteList() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.list.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.api('users/lists/delete', {
+ listId: this.list.id
+ });
+ os.success();
+ this.$router.push('/my/lists');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-list-page {
+ > .members {
+ > ._content {
+ > .users {
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ flex: 1;
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
new file mode 100644
index 0000000000..92d3f399f7
--- /dev/null
+++ b/packages/client/src/pages/not-found.vue
@@ -0,0 +1,25 @@
+<template>
+<div class="ipledcug">
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
+ <div>{{ $ts.notFoundDescription }}</div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.notFound,
+ icon: 'fas fa-exclamation-triangle'
+ },
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
new file mode 100644
index 0000000000..ecd391dfbf
--- /dev/null
+++ b/packages/client/src/pages/note.vue
@@ -0,0 +1,209 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="fcuexfpr">
+ <transition name="fade" mode="out-in">
+ <div v-if="note" class="note">
+ <div class="_gap" v-if="showNext">
+ <XNotes class="_content" :pagination="next" :no-gap="true"/>
+ </div>
+
+ <div class="main _gap">
+ <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
+ <div class="note _gap">
+ <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/>
+ <XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/>
+ </div>
+ <div class="_content clips _gap" v-if="clips && clips.length > 0">
+ <div class="title">{{ $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="fas fa-chevron-down"></i></MkButton>
+ </div>
+
+ <div class="_gap" v-if="showPrev">
+ <XNotes class="_content" :pagination="prev" :no-gap="true"/>
+ </div>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XNote from '@/components/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import XNotes from '@/components/notes.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNote,
+ XNoteDetailed,
+ XNotes,
+ MkRemoteCaution,
+ MkButton,
+ },
+ props: {
+ noteId: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.note ? {
+ title: this.$ts.note,
+ subtitle: new Date(this.note.createdAt).toLocaleString(),
+ avatar: this.note.user,
+ path: `/notes/${this.note.id}`,
+ share: {
+ title: this.$t('noteOf', { user: this.note.user.name }),
+ text: this.note.text,
+ },
+ bg: 'var(--bg)',
+ } : null),
+ note: null,
+ clips: null,
+ hasPrev: false,
+ hasNext: false,
+ showPrev: false,
+ showNext: false,
+ error: null,
+ prev: {
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.note.userId,
+ untilId: this.note.id,
+ })
+ },
+ next: {
+ reversed: true,
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.note.userId,
+ sinceId: this.note.id,
+ })
+ },
+ };
+ },
+ watch: {
+ noteId: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.note = null;
+ os.api('notes/show', {
+ noteId: this.noteId
+ }).then(note => {
+ this.note = note;
+ 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]) => {
+ this.clips = clips;
+ this.hasPrev = prev.length !== 0;
+ this.hasNext = next.length !== 0;
+ });
+ }).catch(e => {
+ this.error = e;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.fcuexfpr {
+ background: var(--bg);
+
+ > .note {
+ > .main {
+ > .load {
+ min-width: 0;
+ margin: 0 auto;
+ border-radius: 999px;
+
+ &.next {
+ margin-bottom: var(--margin);
+ }
+
+ &.prev {
+ margin-top: var(--margin);
+ }
+ }
+
+ > .note {
+ > .note {
+ border-radius: var(--radius);
+ background: var(--panel);
+ }
+ }
+
+ > .clips {
+ > .title {
+ font-weight: bold;
+ padding: 12px;
+ }
+
+ > .item {
+ display: block;
+ padding: 16px;
+
+ > .description {
+ padding: 8px 0;
+ }
+
+ > .user {
+ $height: 32px;
+ padding-top: 16px;
+ border-top: solid 0.5px var(--divider);
+ line-height: $height;
+
+ > .avatar {
+ width: $height;
+ height: $height;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
new file mode 100644
index 0000000000..f8e610a719
--- /dev/null
+++ b/packages/client/src/pages/notifications.vue
@@ -0,0 +1,88 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="clupoqwt">
+ <XNotifications class="notifications" @before="before" @after="after" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotifications from '@/components/notifications.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { notificationTypes } from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XNotifications
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.notifications,
+ icon: 'fas fa-bell',
+ bg: 'var(--bg)',
+ actions: [{
+ text: this.$ts.filter,
+ icon: 'fas fa-filter',
+ highlighted: this.includeTypes != null,
+ handler: this.setFilter,
+ }, {
+ text: this.$ts.markAllAsRead,
+ icon: 'fas fa-check',
+ handler: () => {
+ os.apiWithDialog('notifications/mark-all-as-read');
+ },
+ }],
+ tabs: [{
+ active: this.tab === 'all',
+ title: this.$ts.all,
+ onClick: () => { this.tab = 'all'; },
+ }, {
+ active: this.tab === 'unread',
+ title: this.$ts.unread,
+ onClick: () => { this.tab = 'unread'; },
+ },]
+ })),
+ tab: 'all',
+ includeTypes: null,
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ setFilter(ev) {
+ const typeItems = notificationTypes.map(t => ({
+ text: this.$t(`_notification._types.${t}`),
+ active: this.includeTypes && this.includeTypes.includes(t),
+ action: () => {
+ this.includeTypes = [t];
+ }
+ }));
+ const items = this.includeTypes != null ? [{
+ icon: 'fas fa-times',
+ text: this.$ts.clear,
+ action: () => {
+ this.includeTypes = null;
+ }
+ }, null, ...typeItems] : typeItems;
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.clupoqwt {
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
new file mode 100644
index 0000000000..a25a892eaa
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
@@ -0,0 +1,84 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.button }}</template>
+
+ <section class="xfhsjczc">
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._button.text }}</template></MkInput>
+ <MkSwitch v-model="value.primary"><span>{{ $ts._pages.blocks._button.colored }}</span></MkSwitch>
+ <MkSelect v-model="value.action">
+ <template #label>{{ $ts._pages.blocks._button.action }}</template>
+ <option value="dialog">{{ $ts._pages.blocks._button._action.dialog }}</option>
+ <option value="resetRandom">{{ $ts._pages.blocks._button._action.resetRandom }}</option>
+ <option value="pushEvent">{{ $ts._pages.blocks._button._action.pushEvent }}</option>
+ <option value="callAiScript">{{ $ts._pages.blocks._button._action.callAiScript }}</option>
+ </MkSelect>
+ <template v-if="value.action === 'dialog'">
+ <MkInput v-model="value.content"><template #label>{{ $ts._pages.blocks._button._action._dialog.content }}</template></MkInput>
+ </template>
+ <template v-else-if="value.action === 'pushEvent'">
+ <MkInput v-model="value.event"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.event }}</template></MkInput>
+ <MkInput v-model="value.message"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.message }}</template></MkInput>
+ <MkSelect v-model="value.var">
+ <template #label>{{ $ts._pages.blocks._button._action._pushEvent.variable }}</template>
+ <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option>
+ <option v-for="v in hpml.getVarsByType()" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ </MkSelect>
+ </template>
+ <template v-else-if="value.action === 'callAiScript'">
+ <MkInput v-model="value.fn"><template #label>{{ $ts._pages.blocks._button._action._callAiScript.functionName }}</template></MkInput>
+ </template>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSelect, MkInput, MkSwitch
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ if (this.value.action == null) this.value.action = 'dialog';
+ if (this.value.content == null) this.value.content = null;
+ if (this.value.event == null) this.value.event = null;
+ if (this.value.message == null) this.value.message = null;
+ if (this.value.primary == null) this.value.primary = false;
+ if (this.value.var == null) this.value.var = null;
+ if (this.value.fn == null) this.value.fn = null;
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xfhsjczc {
+ padding: 0 16px 0 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
new file mode 100644
index 0000000000..5d009561e2
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
@@ -0,0 +1,50 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-paint-brush"></i> {{ $ts._pages.blocks.canvas }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._canvas.id }}</template>
+ </MkInput>
+ <MkInput v-model="value.width" type="number">
+ <template #label>{{ $ts._pages.blocks._canvas.width }}</template>
+ <template #suffix>px</template>
+ </MkInput>
+ <MkInput v-model="value.height" type="number">
+ <template #label>{{ $ts._pages.blocks._canvas.height }}</template>
+ <template #suffix>px</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ if (this.value.width == null) this.value.width = 300;
+ if (this.value.height == null) this.value.height = 200;
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
new file mode 100644
index 0000000000..3704c64250
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.counter }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._counter.name }}</template>
+ </MkInput>
+ <MkInput v-model="value.text">
+ <template #label>{{ $ts._pages.blocks._counter.text }}</template>
+ </MkInput>
+ <MkInput v-model="value.inc" type="number">
+ <template #label>{{ $ts._pages.blocks._counter.inc }}</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
new file mode 100644
index 0000000000..f76d59abe3
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
@@ -0,0 +1,84 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-question"></i> {{ $ts._pages.blocks.if }}</template>
+ <template #func>
+ <button @click="add()" class="_button">
+ <i class="fas fa-plus"></i>
+ </button>
+ </template>
+
+ <section class="romcojzs">
+ <MkSelect v-model="value.var">
+ <template #label>{{ $ts._pages.blocks._if.variable }}</template>
+ <option v-for="v in hpml.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ </MkSelect>
+
+ <XBlocks class="children" v-model="value.children" :hpml="hpml"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '@/components/form/select.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSelect,
+ XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.children == null) this.value.children = [];
+ if (this.value.var === undefined) this.value.var = null;
+ },
+
+ methods: {
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.romcojzs {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
new file mode 100644
index 0000000000..396c83f512
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
@@ -0,0 +1,72 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <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 class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkDriveFileThumbnail
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ file: null,
+ };
+ },
+
+ created() {
+ if (this.value.fileId === undefined) this.value.fileId = null;
+ },
+
+ mounted() {
+ if (this.value.fileId == null) {
+ this.choose();
+ } else {
+ os.api('drive/files/show', {
+ fileId: this.value.fileId
+ }).then(file => {
+ this.file = file;
+ });
+ }
+ },
+
+ methods: {
+ async choose() {
+ os.selectDriveFile(false).then(file => {
+ this.file = file;
+ this.value.fileId = file.id;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.oyyftmcf {
+ > .preview {
+ height: 150px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
new file mode 100644
index 0000000000..263b60d3e0
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
@@ -0,0 +1,65 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-sticky-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="value.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch>
+
+ <XNote v-if="note && !value.detailed" v-model:note="note" :key="note.id + ':normal'" style="margin-bottom: 16px;"/>
+ <XNoteDetailed v-if="note && value.detailed" v-model:note="note" :key="note.id + ':detail'" style="margin-bottom: 16px;"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } 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/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput, MkSwitch, XNote, XNoteDetailed,
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ id: this.value.note,
+ note: null,
+ };
+ },
+
+ watch: {
+ id: {
+ async handler() {
+ if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) {
+ this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop();
+ } else {
+ this.value.note = this.id;
+ }
+
+ this.note = await os.api('notes/show', { noteId: this.value.note });
+ },
+ immediate: true
+ },
+ },
+
+ created() {
+ if (this.value.note == null) this.value.note = null;
+ if (this.value.detailed == null) this.value.detailed = false;
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
new file mode 100644
index 0000000000..3a2f4a762b
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.numberInput }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._numberInput.name }}</template>
+ </MkInput>
+ <MkInput v-model="value.text">
+ <template #label>{{ $ts._pages.blocks._numberInput.text }}</template>
+ </MkInput>
+ <MkInput v-model="value.default" type="number">
+ <template #label>{{ $ts._pages.blocks._numberInput.default }}</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
new file mode 100644
index 0000000000..780786144e
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
@@ -0,0 +1,43 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-paper-plane"></i> {{ $ts._pages.blocks.post }}</template>
+
+ <section style="padding: 16px;">
+ <MkTextarea v-model="value.text"><template #label>{{ $ts._pages.blocks._post.text }}</template></MkTextarea>
+ <MkSwitch v-model="value.attachCanvasImage"><span>{{ $ts._pages.blocks._post.attachCanvasImage }}</span></MkSwitch>
+ <MkInput v-if="value.attachCanvasImage" v-model="value.canvasId"><template #label>{{ $ts._pages.blocks._post.canvasId }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput, MkSwitch
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false;
+ if (this.value.canvasId == null) this.value.canvasId = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
new file mode 100644
index 0000000000..f01a47c54a
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
@@ -0,0 +1,50 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.radioButton }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._radioButton.name }}</template></MkInput>
+ <MkInput v-model="value.title"><template #label>{{ $ts._pages.blocks._radioButton.title }}</template></MkInput>
+ <MkTextarea v-model="values"><template #label>{{ $ts._pages.blocks._radioButton.values }}</template></MkTextarea>
+ <MkInput v-model="value.default"><template #label>{{ $ts._pages.blocks._radioButton.default }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+ props: {
+ value: {
+ required: true
+ },
+ },
+ data() {
+ return {
+ values: '',
+ };
+ },
+ watch: {
+ values: {
+ handler() {
+ this.value.values = this.values.split('\n');
+ },
+ deep: true
+ }
+ },
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ if (this.value.title == null) this.value.title = '';
+ if (this.value.values == null) this.value.values = [];
+ this.values = this.value.values.join('\n');
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
new file mode 100644
index 0000000000..16e32d8400
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
@@ -0,0 +1,96 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-sticky-note"></i> {{ value.title }}</template>
+ <template #func>
+ <button @click="rename()" class="_button">
+ <i class="fas fa-pencil-alt"></i>
+ </button>
+ <button @click="add()" class="_button">
+ <i class="fas fa-plus"></i>
+ </button>
+ </template>
+
+ <section class="ilrvjyvi">
+ <XBlocks class="children" v-model="value.children" :hpml="hpml"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer,
+ XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.title == null) this.value.title = null;
+ if (this.value.children == null) this.value.children = [];
+ },
+
+ mounted() {
+ if (this.value.title == null) {
+ this.rename();
+ }
+ },
+
+ methods: {
+ async rename() {
+ const { canceled, result: title } = await os.dialog({
+ title: 'Enter title',
+ input: {
+ type: 'text',
+ default: this.value.title
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.value.title = title;
+ },
+
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ilrvjyvi {
+ > .children {
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
new file mode 100644
index 0000000000..e72f7b44d0
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.switch }}</template>
+
+ <section class="kjuadyyj">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._switch.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._switch.text }}</template></MkInput>
+ <MkSwitch v-model="value.default"><span>{{ $ts._pages.blocks._switch.default }}</span></MkSwitch>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSwitch, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kjuadyyj {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
new file mode 100644
index 0000000000..908862cf07
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
@@ -0,0 +1,39 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textInput }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textInput.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textInput.text }}</template></MkInput>
+ <MkInput v-model="value.default" type="text"><template #label>{{ $ts._pages.blocks._textInput.default }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
new file mode 100644
index 0000000000..05b1a9c67d
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
@@ -0,0 +1,57 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template>
+
+ <section class="vckmsadr">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vckmsadr {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
new file mode 100644
index 0000000000..bb37158ecb
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
@@ -0,0 +1,40 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textareaInput }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textareaInput.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textareaInput.text }}</template></MkInput>
+ <MkTextarea v-model="value.default"><template #label>{{ $ts._pages.blocks._textareaInput.default }}</template></MkTextarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
new file mode 100644
index 0000000000..4ca83da17c
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
@@ -0,0 +1,57 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.textarea }}</template>
+
+ <section class="ihymsbbe">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ihymsbbe {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.blocks.vue b/packages/client/src/pages/page-editor/page-editor.blocks.vue
new file mode 100644
index 0000000000..b91d9abae8
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.blocks.vue
@@ -0,0 +1,78 @@
+<template>
+<XDraggable tag="div" v-model="blocks" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5">
+ <template #item="{element}">
+ <component :is="'x-' + element.type" :value="element" @update:value="updateItem" @remove="() => removeItem(element)" :hpml="hpml"/>
+ </template>
+</XDraggable>
+</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 XTextarea from './els/page-editor.el.textarea.vue';
+import XImage from './els/page-editor.el.image.vue';
+import XButton from './els/page-editor.el.button.vue';
+import XTextInput from './els/page-editor.el.text-input.vue';
+import XTextareaInput from './els/page-editor.el.textarea-input.vue';
+import XNumberInput from './els/page-editor.el.number-input.vue';
+import XSwitch from './els/page-editor.el.switch.vue';
+import XIf from './els/page-editor.el.if.vue';
+import XPost from './els/page-editor.el.post.vue';
+import XCounter from './els/page-editor.el.counter.vue';
+import XRadioButton from './els/page-editor.el.radio-button.vue';
+import XCanvas from './els/page-editor.el.canvas.vue';
+import XNote from './els/page-editor.el.note.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas, XNote
+ },
+
+ props: {
+ modelValue: {
+ type: Array,
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ emits: ['update:modelValue'],
+
+ computed: {
+ blocks: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit('update:modelValue', value);
+ }
+ }
+ },
+
+ methods: {
+ updateItem(v) {
+ const i = this.blocks.findIndex(x => x.id === v.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ v,
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('update:modelValue', newValue);
+ },
+
+ removeItem(el) {
+ const i = this.blocks.findIndex(x => x.id === el.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('update:modelValue', newValue);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/page-editor.container.vue b/packages/client/src/pages/page-editor/page-editor.container.vue
new file mode 100644
index 0000000000..afd261fac7
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.container.vue
@@ -0,0 +1,159 @@
+<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" @click="remove()" class="_button">
+ <i class="fas fa-trash-alt"></i>
+ </button>
+ <button v-if="draggable" class="drag-handle _button">
+ <i class="fas fa-bars"></i>
+ </button>
+ <button @click="toggleContent(!showBody)" class="_button">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </div>
+ </header>
+ <p v-show="showBody" class="error" v-if="error != null">{{ $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" class="warn" v-if="warn != null">{{ $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: 6px;
+
+ &:hover {
+ border: solid 2px var(--X13);
+ }
+
+ &.warn {
+ border: solid 2px #dec44c;
+ }
+
+ &.error {
+ border: solid 2px #f00;
+ }
+
+ & + .cpjygsrt {
+ margin-top: 16px;
+ }
+
+ > header {
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ box-shadow: 0 1px rgba(#000, 0.07);
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+
+ .drag-handle {
+ cursor: move;
+ }
+ }
+ }
+
+ > .warn {
+ color: #b19e49;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+
+ > .error {
+ color: #f00;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+
+ > .body {
+ ::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
+ &:not(.inline):first-child {
+ margin-top: 28px;
+ }
+
+ &:not(.inline):last-child {
+ margin-bottom: 20px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.script-block.vue b/packages/client/src/pages/page-editor/page-editor.script-block.vue
new file mode 100644
index 0000000000..07958c902b
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.script-block.vue
@@ -0,0 +1,281 @@
+<template>
+<XContainer :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable">
+ <template #header><i v-if="icon" :class="icon"></i> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template>
+ <template #func>
+ <button @click="changeType()" class="_button">
+ <i class="fas fa-pencil-alt"></i>
+ </button>
+ </template>
+
+ <section v-if="modelValue.type === null" class="pbglfege" @click="changeType()">
+ {{ $ts._pages.script.emptySlot }}
+ </section>
+ <section v-else-if="modelValue.type === 'text'" class="tbwccoaw">
+ <input v-model="modelValue.value"/>
+ </section>
+ <section v-else-if="modelValue.type === 'multiLineText'" class="tbwccoaw">
+ <textarea v-model="modelValue.value"></textarea>
+ </section>
+ <section v-else-if="modelValue.type === 'textList'" class="tbwccoaw">
+ <textarea v-model="modelValue.value" :placeholder="$ts._pages.script.blocks._textList.info"></textarea>
+ </section>
+ <section v-else-if="modelValue.type === 'number'" class="tbwccoaw">
+ <input v-model="modelValue.value" type="number"/>
+ </section>
+ <section v-else-if="modelValue.type === 'ref'" class="hpdwcrvs">
+ <select v-model="modelValue.value">
+ <option v-for="v in hpml.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.argVariables">
+ <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ </select>
+ </section>
+ <section v-else-if="modelValue.type === 'aiScriptVar'" class="tbwccoaw">
+ <input v-model="modelValue.value"/>
+ </section>
+ <section v-else-if="modelValue.type === 'fn'" class="" style="padding:0 16px 16px 16px;">
+ <MkTextarea v-model="slots">
+ <template #label>{{ $ts._pages.script.blocks._fn.slots }}</template>
+ <template #caption>{{ $t('_pages.script.blocks._fn.slots-info') }}</template>
+ </MkTextarea>
+ <XV v-if="modelValue.value.expression" v-model="modelValue.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/>
+ </section>
+ <section v-else-if="modelValue.type.startsWith('fn:')" class="" style="padding:16px;">
+ <XV v-for="(x, i) in modelValue.args" v-model="value.args[i]" :title="hpml.getVarByName(modelValue.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/>
+ </section>
+ <section v-else class="" style="padding:16px;">
+ <XV v-for="(x, i) in modelValue.args" v-model="modelValue.args[i]" :title="$t(`_pages.script.blocks._${modelValue.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from './page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import { blockDefs } from '@/scripts/hpml/index';
+import * as os from '@/os';
+import { isLiteralValue } from '@/scripts/hpml/expr';
+import { funcDefs } from '@/scripts/hpml/lib';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea,
+ XV: defineAsyncComponent(() => import('./page-editor.script-block.vue')),
+ },
+
+ inject: ['getScriptBlockList'],
+
+ props: {
+ getExpectedType: {
+ required: false,
+ default: null
+ },
+ modelValue: {
+ required: true
+ },
+ title: {
+ required: false
+ },
+ removable: {
+ required: false,
+ default: false
+ },
+ hpml: {
+ required: true,
+ },
+ name: {
+ required: true,
+ },
+ fnSlots: {
+ required: false,
+ },
+ draggable: {
+ required: false,
+ default: false
+ }
+ },
+
+ data() {
+ return {
+ error: null,
+ warn: null,
+ slots: '',
+ };
+ },
+
+ computed: {
+ icon(): any {
+ if (this.modelValue.type === null) return null;
+ if (this.modelValue.type.startsWith('fn:')) return 'fas fa-plug';
+ return blockDefs.find(x => x.type === this.modelValue.type).icon;
+ },
+ typeText(): any {
+ if (this.modelValue.type === null) return null;
+ if (this.modelValue.type.startsWith('fn:')) return this.modelValue.type.split(':')[1];
+ return this.$t(`_pages.script.blocks.${this.modelValue.type}`);
+ },
+ },
+
+ watch: {
+ slots: {
+ handler() {
+ this.modelValue.value.slots = this.slots.split('\n').map(x => ({
+ name: x,
+ type: null
+ }));
+ },
+ deep: true
+ }
+ },
+
+ created() {
+ if (this.modelValue.value == null) this.modelValue.value = null;
+
+ if (this.modelValue.value && this.modelValue.value.slots) this.slots = this.modelValue.value.slots.map(x => x.name).join('\n');
+
+ this.$watch(() => this.modelValue.type, (t) => {
+ this.warn = null;
+
+ if (this.modelValue.type === 'fn') {
+ const id = uuid();
+ this.modelValue.value = {
+ slots: [],
+ expression: { id, type: null }
+ };
+ return;
+ }
+
+ if (this.modelValue.type && this.modelValue.type.startsWith('fn:')) {
+ const fnName = this.modelValue.type.split(':')[1];
+ const fn = this.hpml.getVarByName(fnName);
+
+ const empties = [];
+ for (let i = 0; i < fn.value.slots.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ this.modelValue.args = empties;
+ return;
+ }
+
+ if (isLiteralValue(this.modelValue)) return;
+
+ const empties = [];
+ for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ this.modelValue.args = empties;
+
+ for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) {
+ const inType = funcDefs[this.modelValue.type].in[i];
+ if (typeof inType !== 'number') {
+ if (inType === 'number') this.modelValue.args[i].type = 'number';
+ if (inType === 'string') this.modelValue.args[i].type = 'text';
+ }
+ }
+ });
+
+ this.$watch(() => this.modelValue.args, (args) => {
+ if (args == null) {
+ this.warn = null;
+ return;
+ }
+ const emptySlotIndex = args.findIndex(x => x.type === null);
+ if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
+ this.warn = {
+ slot: emptySlotIndex
+ };
+ } else {
+ this.warn = null;
+ }
+ }, {
+ deep: true
+ });
+
+ this.$watch(() => this.hpml.variables, () => {
+ if (this.type != null && this.modelValue) {
+ this.error = this.hpml.typeCheck(this.modelValue);
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ async changeType() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.selectType,
+ select: {
+ groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null)
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.modelValue.type = type;
+ },
+
+ _getExpectedType(slot: number) {
+ return this.hpml.getExpectedType(this.modelValue, slot);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.turmquns {
+ opacity: 0.7;
+}
+
+.pbglfege {
+ opacity: 0.5;
+ padding: 16px;
+ text-align: center;
+ cursor: pointer;
+ color: var(--fg);
+}
+
+.tbwccoaw {
+ > input,
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ font-size: 16px;
+ background: transparent;
+ color: var(--fg);
+ box-sizing: border-box;
+ }
+
+ > textarea {
+ min-height: 100px;
+ }
+}
+
+.hpdwcrvs {
+ padding: 16px;
+
+ > select {
+ display: block;
+ padding: 4px;
+ font-size: 16px;
+ width: 100%;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
new file mode 100644
index 0000000000..684b1f8c75
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -0,0 +1,561 @@
+<template>
+<div>
+ <div class="jqqmcavi" style="margin: 16px;">
+ <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
+ <MkButton inline @click="save" primary class="button" v-if="!readonly"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="duplicate" class="button" v-if="pageId"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton>
+ <MkButton inline @click="del" class="button" v-if="pageId && !readonly" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+
+ <div v-if="tab === 'settings'">
+ <div style="padding: 16px;" 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="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
+ <div v-else-if="eyeCatchingImage">
+ <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
+ <MkButton @click="removeEyeCatchingImage()" v-if="!readonly"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'contents'">
+ <div style="padding: 16px;">
+ <XBlocks class="content" v-model="content" :hpml="hpml"/>
+
+ <MkButton @click="add()" v-if="!readonly"><i class="fas fa-plus"></i></MkButton>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'variables'">
+ <div class="qmuvgica">
+ <XDraggable tag="div" class="variables" v-show="variables.length > 0" v-model="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
+ <template #item="{element}">
+ <XVariable
+ :modelValue="element"
+ :removable="true"
+ @remove="() => removeVariable(element)"
+ :hpml="hpml"
+ :name="element.name"
+ :title="element.name"
+ :draggable="true"
+ />
+ </template>
+ </XDraggable>
+
+ <MkButton @click="addVariable()" class="add" v-if="!readonly"><i class="fas fa-plus"></i></MkButton>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'script'">
+ <div>
+ <MkTextarea class="_code" v-model="script"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } 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 'vue-prism-editor/dist/prismeditor.min.css';
+import { v4 as uuid } from 'uuid';
+import XVariable from './page-editor.script-block.vue';
+import XBlocks from './page-editor.blocks.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInput from '@/components/form/input.vue';
+import { blockDefs } from '@/scripts/hpml/index';
+import { HpmlTypeChecker } from '@/scripts/hpml/type-checker';
+import { url } from '@/config';
+import { collectPageVars } from '@/scripts/collect-page-vars';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput,
+ },
+
+ props: {
+ initPageId: {
+ type: String,
+ required: false
+ },
+ initPageName: {
+ type: String,
+ required: false
+ },
+ initUser: {
+ type: String,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => {
+ let title = this.$ts._pages.newPage;
+ if (this.initPageId) {
+ title = this.$ts._pages.editPage;
+ }
+ else if (this.initPageName && this.initUser) {
+ title = this.$ts._pages.readPage;
+ }
+ return {
+ title: title,
+ icon: 'fas fa-pencil-alt',
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.tab === 'settings',
+ title: this.$ts._pages.pageSetting,
+ icon: 'fas fa-cog',
+ onClick: () => { this.tab = 'settings'; },
+ }, {
+ active: this.tab === 'contents',
+ title: this.$ts._pages.contents,
+ icon: 'fas fa-sticky-note',
+ onClick: () => { this.tab = 'contents'; },
+ }, {
+ active: this.tab === 'variables',
+ title: this.$ts._pages.variables,
+ icon: 'fas fa-magic',
+ onClick: () => { this.tab = 'variables'; },
+ }, {
+ active: this.tab === 'script',
+ title: this.$ts.script,
+ icon: 'fas fa-code',
+ onClick: () => { this.tab = 'script'; },
+ }],
+ };
+ }),
+ tab: 'settings',
+ author: this.$i,
+ readonly: false,
+ page: null,
+ pageId: null,
+ currentName: null,
+ title: '',
+ summary: null,
+ name: Date.now().toString(),
+ eyeCatchingImage: null,
+ eyeCatchingImageId: null,
+ font: 'sans-serif',
+ content: [],
+ alignCenter: false,
+ hideTitleWhenPinned: false,
+ variables: [],
+ hpml: null,
+ script: '',
+ url,
+ };
+ },
+
+ watch: {
+ async eyeCatchingImageId() {
+ if (this.eyeCatchingImageId == null) {
+ this.eyeCatchingImage = null;
+ } else {
+ this.eyeCatchingImage = await os.api('drive/files/show', {
+ fileId: this.eyeCatchingImageId,
+ });
+ }
+ },
+ },
+
+ async created() {
+ this.hpml = new HpmlTypeChecker();
+
+ this.$watch('variables', () => {
+ this.hpml.variables = this.variables;
+ }, { deep: true });
+
+ this.$watch('content', () => {
+ this.hpml.pageVars = collectPageVars(this.content);
+ }, { deep: true });
+
+ if (this.initPageId) {
+ this.page = await os.api('pages/show', {
+ pageId: this.initPageId,
+ });
+ } else if (this.initPageName && this.initUser) {
+ this.page = await os.api('pages/show', {
+ name: this.initPageName,
+ username: this.initUser,
+ });
+ this.readonly = true;
+ }
+
+ if (this.page) {
+ this.author = this.page.user;
+ this.pageId = this.page.id;
+ this.title = this.page.title;
+ this.name = this.page.name;
+ this.currentName = this.page.name;
+ this.summary = this.page.summary;
+ this.font = this.page.font;
+ this.script = this.page.script;
+ this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
+ this.alignCenter = this.page.alignCenter;
+ this.content = this.page.content;
+ this.variables = this.page.variables;
+ this.eyeCatchingImageId = this.page.eyeCatchingImageId;
+ } else {
+ const id = uuid();
+ this.content = [{
+ id,
+ type: 'text',
+ text: 'Hello World!'
+ }];
+ }
+ },
+
+ provide() {
+ return {
+ readonly: this.readonly,
+ getScriptBlockList: this.getScriptBlockList,
+ getPageBlockList: this.getPageBlockList
+ }
+ },
+
+ methods: {
+ getSaveOptions() {
+ return {
+ title: this.title.trim(),
+ name: this.name.trim(),
+ summary: this.summary,
+ font: this.font,
+ script: this.script,
+ hideTitleWhenPinned: this.hideTitleWhenPinned,
+ alignCenter: this.alignCenter,
+ content: this.content,
+ variables: this.variables,
+ eyeCatchingImageId: this.eyeCatchingImageId,
+ };
+ },
+
+ save() {
+ const options = this.getSaveOptions();
+
+ const onError = err => {
+ if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
+ if (err.info.param == 'name') {
+ os.dialog({
+ type: 'error',
+ title: this.$ts._pages.invalidNameTitle,
+ text: this.$ts._pages.invalidNameText
+ });
+ }
+ } else if (err.code == 'NAME_ALREADY_EXISTS') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._pages.nameAlreadyExists
+ });
+ }
+ };
+
+ if (this.pageId) {
+ options.pageId = this.pageId;
+ os.api('pages/update', options)
+ .then(page => {
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.updated
+ });
+ }).catch(onError);
+ } else {
+ os.api('pages/create', options)
+ .then(page => {
+ this.pageId = page.id;
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.created
+ });
+ this.$router.push(`/pages/edit/${this.pageId}`);
+ }).catch(onError);
+ }
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.title.trim() }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.api('pages/delete', {
+ pageId: this.pageId,
+ }).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.deleted
+ });
+ this.$router.push(`/pages`);
+ });
+ });
+ },
+
+ duplicate() {
+ this.title = this.title + ' - copy';
+ this.name = this.name + '-copy';
+ os.api('pages/create', this.getSaveOptions()).then(page => {
+ this.pageId = page.id;
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.created
+ });
+ this.$router.push(`/pages/edit/${this.pageId}`);
+ });
+ },
+
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.content.push({ id, type });
+ },
+
+ async addVariable() {
+ let { canceled, result: name } = await os.dialog({
+ title: this.$ts._pages.enterVariableName,
+ input: {
+ type: 'text',
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ name = name.trim();
+
+ if (this.hpml.isUsedName(name)) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._pages.variableNameIsAlreadyUsed
+ });
+ return;
+ }
+
+ const id = uuid();
+ this.variables.push({ id, name, type: null });
+ },
+
+ removeVariable(v) {
+ this.variables = this.variables.filter(x => x.name !== v.name);
+ },
+
+ getPageBlockList() {
+ return [{
+ label: this.$ts._pages.contentBlocks,
+ items: [
+ { value: 'section', text: this.$ts._pages.blocks.section },
+ { value: 'text', text: this.$ts._pages.blocks.text },
+ { value: 'image', text: this.$ts._pages.blocks.image },
+ { value: 'textarea', text: this.$ts._pages.blocks.textarea },
+ { value: 'note', text: this.$ts._pages.blocks.note },
+ { value: 'canvas', text: this.$ts._pages.blocks.canvas },
+ ]
+ }, {
+ label: this.$ts._pages.inputBlocks,
+ items: [
+ { value: 'button', text: this.$ts._pages.blocks.button },
+ { value: 'radioButton', text: this.$ts._pages.blocks.radioButton },
+ { value: 'textInput', text: this.$ts._pages.blocks.textInput },
+ { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput },
+ { value: 'numberInput', text: this.$ts._pages.blocks.numberInput },
+ { value: 'switch', text: this.$ts._pages.blocks.switch },
+ { value: 'counter', text: this.$ts._pages.blocks.counter }
+ ]
+ }, {
+ label: this.$ts._pages.specialBlocks,
+ items: [
+ { value: 'if', text: this.$ts._pages.blocks.if },
+ { value: 'post', text: this.$ts._pages.blocks.post }
+ ]
+ }];
+ },
+
+ getScriptBlockList(type: string = null) {
+ const list = [];
+
+ const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+
+ for (const block of blocks) {
+ const category = list.find(x => x.category === block.category);
+ if (category) {
+ category.items.push({
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ });
+ } else {
+ list.push({
+ category: block.category,
+ label: this.$t(`_pages.script.categories.${block.category}`),
+ items: [{
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ }]
+ });
+ }
+ }
+
+ const userFns = this.variables.filter(x => x.type === 'fn');
+ if (userFns.length > 0) {
+ list.unshift({
+ label: this.$t(`_pages.script.categories.fn`),
+ items: userFns.map(v => ({
+ value: 'fn:' + v.name,
+ text: v.name
+ }))
+ });
+ }
+
+ return list;
+ },
+
+ setEyeCatchingImage(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ this.eyeCatchingImageId = file.id;
+ });
+ },
+
+ removeEyeCatchingImage() {
+ this.eyeCatchingImageId = null;
+ },
+
+ highlighter(code) {
+ return highlight(code, languages.js, 'javascript');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jqqmcavi {
+ > .button {
+ & + .button {
+ margin-left: 8px;
+ }
+ }
+}
+
+.gwbmwxkm {
+ position: relative;
+
+ > header {
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ box-shadow: 0 1px rgba(#000, 0.07);
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+ }
+ }
+
+ > section {
+ padding: 0 32px 32px 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px 16px 16px;
+ }
+
+ > .view {
+ display: inline-block;
+ margin: 16px 0 0 0;
+ font-size: 14px;
+ }
+
+ > .content {
+ margin-bottom: 16px;
+ }
+
+ > .eyeCatch {
+ margin-bottom: 16px;
+
+ > div {
+ > img {
+ max-width: 100%;
+ }
+ }
+ }
+ }
+}
+
+.qmuvgica {
+ padding: 16px;
+
+ > .variables {
+ margin-bottom: 16px;
+ }
+
+ > .add {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
new file mode 100644
index 0000000000..1eff1a98cb
--- /dev/null
+++ b/packages/client/src/pages/page.vue
@@ -0,0 +1,311 @@
+<template>
+<div>
+ <transition name="fade" mode="out-in">
+ <div v-if="page" class="xcukqgmh" :key="page.id" v-size="{ max: [450] }">
+ <div class="_block main">
+ <!--
+ <div class="header">
+ <h1>{{ page.title }}</h1>
+ </div>
+ -->
+ <div class="banner">
+ <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/>
+ </div>
+ <div class="content">
+ <XPage :page="page"/>
+ </div>
+ <div class="actions">
+ <div class="like">
+ <MkButton class="button" @click="unlike()" v-if="page.isLiked" v-tooltip="$ts._pages.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton>
+ <MkButton class="button" @click="like()" v-else v-tooltip="$ts._pages.like"><i class="far fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button>
+ <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-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">{{ $ts._pages.viewSource }}</MkA>
+ <template v-if="$i && $i.id === page.userId">
+ <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA>
+ <button v-if="$i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $ts.unpin }}</button>
+ <button v-else @click="pin(true)" class="link _textButton">{{ $ts.pin }}</button>
+ </template>
+ </div>
+ </div>
+ <div class="footer">
+ <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+ <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $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="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="otherPostsPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/>
+ </MkPagination>
+ </MkContainer>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XPage from '@/components/page/page.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkPagePreview from '@/components/page-preview.vue';
+
+export default defineComponent({
+ components: {
+ XPage,
+ MkButton,
+ MkFollowButton,
+ MkContainer,
+ MkPagination,
+ MkPagePreview,
+ },
+
+ props: {
+ pageName: {
+ type: String,
+ required: true
+ },
+ username: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.page ? {
+ title: computed(() => this.page.title || this.page.name),
+ avatar: this.page.user,
+ path: `/@${this.page.user.username}/pages/${this.page.name}`,
+ share: {
+ title: this.page.title || this.page.name,
+ text: this.page.summary,
+ },
+ } : null),
+ page: null,
+ error: null,
+ otherPostsPagination: {
+ endpoint: 'users/pages',
+ limit: 6,
+ params: () => ({
+ userId: this.page.user.id
+ })
+ },
+ };
+ },
+
+ computed: {
+ path(): string {
+ return this.username + '/' + this.pageName;
+ }
+ },
+
+ watch: {
+ path() {
+ this.fetch();
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ this.page = null;
+ os.api('pages/show', {
+ name: this.pageName,
+ username: this.username,
+ }).then(page => {
+ this.page = page;
+ }).catch(e => {
+ this.error = e;
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.page.title || this.page.name,
+ text: this.page.summary,
+ url: `${url}/@${this.page.user.username}/pages/${this.page.name}`
+ });
+ },
+
+ shareWithNote() {
+ os.post({
+ initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}`
+ });
+ },
+
+ like() {
+ os.apiWithDialog('pages/like', {
+ pageId: this.page.id,
+ }).then(() => {
+ this.page.isLiked = true;
+ this.page.likedCount++;
+ });
+ },
+
+ async unlike() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('pages/unlike', {
+ pageId: this.page.id,
+ }).then(() => {
+ this.page.isLiked = false;
+ this.page.likedCount--;
+ });
+ },
+
+ pin(pin) {
+ os.apiWithDialog('i/update', {
+ pinnedPageId: pin ? this.page.id : 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 {
+ --padding: 32px;
+
+ &.max-width_450px {
+ --padding: 16px;
+ }
+
+ > .main {
+ padding: var(--padding);
+
+ > .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(--padding);
+ font-size: 85%;
+ opacity: 0.75;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
new file mode 100644
index 0000000000..d66fc2ad5b
--- /dev/null
+++ b/packages/client/src/pages/pages.vue
@@ -0,0 +1,96 @@
+<template>
+<MkSpacer>
+ <!-- TODO: MkHeaderใซ็ตฑๅˆ -->
+ <MkTab v-model="tab" v-if="$i">
+ <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._pages.featured }}</option>
+ <option value="my"><i class="fas fa-edit"></i> {{ $ts._pages.my }}</option>
+ <option value="liked"><i class="fas fa-heart"></i> {{ $ts._pages.liked }}</option>
+ </MkTab>
+
+ <div class="_section">
+ <div class="rknalgpo _content" v-if="tab === 'featured'">
+ <MkPagination :pagination="featuredPagesPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="rknalgpo _content my" v-if="tab === 'my'">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination :pagination="myPagesPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="rknalgpo _content" v-if="tab === 'liked'">
+ <MkPagination :pagination="likedPagesPagination" #default="{items}">
+ <MkPagePreview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/>
+ </MkPagination>
+ </div>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagePreview from '@/components/page-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagePreview, MkPagination, MkButton, MkTab
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.pages,
+ icon: 'fas fa-sticky-note',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-plus',
+ text: this.$ts.create,
+ handler: this.create,
+ }],
+ },
+ tab: 'featured',
+ featuredPagesPagination: {
+ endpoint: 'pages/featured',
+ noPaging: true,
+ },
+ myPagesPagination: {
+ endpoint: 'i/pages',
+ limit: 5,
+ },
+ likedPagesPagination: {
+ endpoint: 'i/page-likes',
+ limit: 5,
+ },
+ };
+ },
+ methods: {
+ create() {
+ this.$router.push(`/pages/new`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rknalgpo {
+ &.my .ckltabjg:first-child {
+ margin-top: 16px;
+ }
+
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ @media (min-width: 500px) {
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 16px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
new file mode 100644
index 0000000000..9d1ebb74ed
--- /dev/null
+++ b/packages/client/src/pages/preview.vue
@@ -0,0 +1,32 @@
+<template>
+<div class="graojtoi">
+ <MkSample/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkSample from '@/components/sample.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkSample,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.preview,
+ icon: 'fas fa-eye',
+ },
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.graojtoi {
+ padding: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
new file mode 100644
index 0000000000..f9a2500840
--- /dev/null
+++ b/packages/client/src/pages/reset-password.vue
@@ -0,0 +1,69 @@
+<template>
+<FormBase v-if="token">
+ <FormInput v-model="password" type="password">
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <span>{{ $ts.newPassword }}</span>
+ </FormInput>
+
+ <FormButton primary @click="save">{{ $ts.save }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormInput,
+ FormButton,
+ },
+
+ props: {
+ token: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.resetPassword,
+ icon: 'fas fa-lock'
+ },
+ password: '',
+ }
+ },
+
+ mounted() {
+ if (this.token == null) {
+ os.popup(import('@/components/forgot-password.vue'), {}, {}, 'closed');
+ this.$router.push('/');
+ }
+ },
+
+ methods: {
+ async save() {
+ await os.apiWithDialog('reset-password', {
+ token: this.token,
+ password: this.password,
+ });
+ this.$router.push('/');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/reversi/game.board.vue b/packages/client/src/pages/reversi/game.board.vue
new file mode 100644
index 0000000000..529e00d969
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.board.vue
@@ -0,0 +1,528 @@
+<template>
+<div class="xqnhankfuuilcwvhgsopeqncafzsquya">
+ <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ $ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ $ts._reversi.white }})</header>
+
+ <div style="overflow: hidden; line-height: 28px;">
+ <p class="turn" v-if="!iAmPlayer && !game.isEnded">
+ <Mfm :key="'turn:' + turnUser().name" :text="$t('_reversi.turnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/>
+ <MkEllipsis/>
+ </p>
+ <p class="turn" v-if="logPos != logs.length">
+ <Mfm :key="'past-turn-of:' + turnUser().name" :text="$t('_reversi.pastTurnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/>
+ </p>
+ <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn()">{{ $ts._reversi.opponentTurn }}<MkEllipsis/></p>
+ <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn()" style="animation: tada 1s linear infinite both;">{{ $ts._reversi.myTurn }}</p>
+ <p class="result" v-if="game.isEnded && logPos == logs.length">
+ <template v-if="game.winner">
+ <Mfm :key="'won'" :text="$t('_reversi.won', { name: game.winner.name })" :plain="true" :custom-emojis="game.winner.emojis"/>
+ <span v-if="game.surrendered != null"> ({{ $ts._reversi.surrendered }})</span>
+ </template>
+ <template v-else>{{ $ts._reversi.drawn }}</template>
+ </p>
+ </div>
+
+ <div class="board">
+ <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels">
+ <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ <div class="flex">
+ <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels">
+ <div v-for="i in game.map.length">{{ i }}</div>
+ </div>
+ <div class="cells" :style="cellsStyle">
+ <div v-for="(stone, i) in o.board"
+ :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn(), can: turnUser() ? o.canPut(turnUser().id == blackUser.id, i) : null, prev: o.prevPos == i }"
+ @click="set(i)"
+ :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`"
+ >
+ <template v-if="$store.state.gamesReversiUseAvatarStones || true">
+ <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black">
+ <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white">
+ </template>
+ <template v-else>
+ <i v-if="stone === true" class="fas fa-circle"></i>
+ <i v-if="stone === false" class="far fa-circle"></i>
+ </template>
+ </div>
+ </div>
+ <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels">
+ <div v-for="i in game.map.length">{{ i }}</div>
+ </div>
+ </div>
+ <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels">
+ <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ </div>
+
+ <p class="status"><b>{{ $t('_reversi.turnCount', { count: logPos }) }}</b> {{ $ts._reversi.black }}:{{ o.blackCount }} {{ $ts._reversi.white }}:{{ o.whiteCount }} {{ $ts._reversi.total }}:{{ o.blackCount + o.whiteCount }}</p>
+
+ <div class="actions" v-if="!game.isEnded && iAmPlayer">
+ <MkButton @click="surrender" inline>{{ $ts._reversi.surrender }}</MkButton>
+ </div>
+
+ <div class="player" v-if="game.isEnded">
+ <span>{{ logPos }} / {{ logs.length }}</span>
+ <div class="buttons" v-if="!autoplaying">
+ <MkButton inline @click="logPos = 0" :disabled="logPos == 0"><i class="fas fa-angle-double-left"></i></MkButton>
+ <MkButton inline @click="logPos--" :disabled="logPos == 0"><i class="fas fa-angle-left"></i></MkButton>
+ <MkButton inline @click="logPos++" :disabled="logPos == logs.length"><i class="fas fa-angle-right"></i></MkButton>
+ <MkButton inline @click="logPos = logs.length" :disabled="logPos == logs.length"><i class="fas fa-angle-double-right"></i></MkButton>
+ </div>
+ <MkButton @click="autoplay()" :disabled="autoplaying" style="margin: var(--margin) auto 0 auto;"><i class="fas fa-play"></i></MkButton>
+ </div>
+
+ <div class="info">
+ <p v-if="game.isLlotheo">{{ $ts._reversi.isLlotheo }}</p>
+ <p v-if="game.loopedBoard">{{ $ts._reversi.loopedMap }}</p>
+ <p v-if="game.canPutEverywhere">{{ $ts._reversi.canPutEverywhere }}</p>
+ </div>
+
+ <div class="watchers">
+ <MkAvatar v-for="user in watchers" :key="user.id" :user="user" class="avatar"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as CRC32 from 'crc-32';
+import Reversi, { Color } from '@/scripts/games/reversi/core';
+import { url } from '@/config';
+import MkButton from '@/components/ui/button.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ initGame: {
+ type: Object,
+ require: true
+ },
+ connection: {
+ type: Object,
+ require: true
+ },
+ },
+
+ data() {
+ return {
+ game: JSON.parse(JSON.stringify(this.initGame)),
+ o: null as Reversi,
+ logs: [],
+ logPos: 0,
+ watchers: [],
+ pollingClock: null,
+ };
+ },
+
+ computed: {
+ iAmPlayer(): boolean {
+ if (!this.$i) return false;
+ return this.game.user1Id == this.$i.id || this.game.user2Id == this.$i.id;
+ },
+
+ myColor(): Color {
+ if (!this.iAmPlayer) return null;
+ if (this.game.user1Id == this.$i.id && this.game.black == 1) return true;
+ if (this.game.user2Id == this.$i.id && this.game.black == 2) return true;
+ return false;
+ },
+
+ opColor(): Color {
+ if (!this.iAmPlayer) return null;
+ return this.myColor === true ? false : true;
+ },
+
+ blackUser(): any {
+ return this.game.black == 1 ? this.game.user1 : this.game.user2;
+ },
+
+ whiteUser(): any {
+ return this.game.black == 1 ? this.game.user2 : this.game.user1;
+ },
+
+ cellsStyle(): any {
+ return {
+ 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`,
+ 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)`
+ };
+ }
+ },
+
+ watch: {
+ logPos(v) {
+ if (!this.game.isEnded) return;
+ const o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+ for (const log of this.logs.slice(0, v)) {
+ o.put(log.color, log.pos);
+ }
+ this.o = o;
+ //this.$forceUpdate();
+ }
+ },
+
+ created() {
+ this.o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+
+ for (const log of this.game.logs) {
+ this.o.put(log.color, log.pos);
+ }
+
+ this.logs = this.game.logs;
+ this.logPos = this.logs.length;
+
+ // ้€šไฟกใ‚’ๅ–ใ‚Šใ“ใผใ—ใฆใ‚‚ใ„ใ„ใ‚ˆใ†ใซๅฎšๆœŸ็š„ใซใƒใƒผใƒชใƒณใ‚ฐใ•ใ›ใ‚‹
+ if (this.game.isStarted && !this.game.isEnded) {
+ this.pollingClock = setInterval(() => {
+ if (this.game.isEnded) return;
+ const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join(''));
+ this.connection.send('check', {
+ crc32: crc32
+ });
+ }, 3000);
+ }
+ },
+
+ mounted() {
+ this.connection.on('set', this.onSet);
+ this.connection.on('rescue', this.onRescue);
+ this.connection.on('ended', this.onEnded);
+ this.connection.on('watchers', this.onWatchers);
+ },
+
+ beforeUnmount() {
+ this.connection.off('set', this.onSet);
+ this.connection.off('rescue', this.onRescue);
+ this.connection.off('ended', this.onEnded);
+ this.connection.off('watchers', this.onWatchers);
+
+ clearInterval(this.pollingClock);
+ },
+
+ methods: {
+ userPage,
+
+ // this.o ใŒใƒชใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ–ใซใชใฃใŸๆŠ˜ใซใฏcomputedใซใงใใ‚‹
+ turnUser(): any {
+ if (this.o.turn === true) {
+ return this.game.black == 1 ? this.game.user1 : this.game.user2;
+ } else if (this.o.turn === false) {
+ return this.game.black == 1 ? this.game.user2 : this.game.user1;
+ } else {
+ return null;
+ }
+ },
+
+ // this.o ใŒใƒชใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ–ใซใชใฃใŸๆŠ˜ใซใฏcomputedใซใงใใ‚‹
+ isMyTurn(): boolean {
+ if (!this.iAmPlayer) return false;
+ if (this.turnUser() == null) return false;
+ return this.turnUser().id == this.$i.id;
+ },
+
+ set(pos) {
+ if (this.game.isEnded) return;
+ if (!this.iAmPlayer) return;
+ if (!this.isMyTurn()) return;
+ if (!this.o.canPut(this.myColor, pos)) return;
+
+ this.o.put(this.myColor, pos);
+
+ // ใ‚ตใ‚ฆใƒณใƒ‰ใ‚’ๅ†็”Ÿใ™ใ‚‹
+ sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite');
+
+ this.connection.send('set', {
+ pos: pos
+ });
+
+ this.checkEnd();
+
+ this.$forceUpdate();
+ },
+
+ onSet(x) {
+ this.logs.push(x);
+ this.logPos++;
+ this.o.put(x.color, x.pos);
+ this.checkEnd();
+ this.$forceUpdate();
+
+ // ใ‚ตใ‚ฆใƒณใƒ‰ใ‚’ๅ†็”Ÿใ™ใ‚‹
+ if (x.color !== this.myColor) {
+ sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+ }
+ },
+
+ onEnded(x) {
+ this.game = JSON.parse(JSON.stringify(x.game));
+ },
+
+ checkEnd() {
+ this.game.isEnded = this.o.isEnded;
+ if (this.game.isEnded) {
+ if (this.o.winner === true) {
+ this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id;
+ this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2;
+ } else if (this.o.winner === false) {
+ this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id;
+ this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1;
+ } else {
+ this.game.winnerId = null;
+ this.game.winner = null;
+ }
+ }
+ },
+
+ // ๆญฃใ—ใ„ใ‚ฒใƒผใƒ ๆƒ…ๅ ฑใŒ้€ใ‚‰ใ‚ŒใฆใใŸใจใ
+ onRescue(game) {
+ this.game = JSON.parse(JSON.stringify(game));
+
+ this.o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+
+ for (const log of this.game.logs) {
+ this.o.put(log.color, log.pos, true);
+ }
+
+ this.logs = this.game.logs;
+ this.logPos = this.logs.length;
+
+ this.checkEnd();
+ this.$forceUpdate();
+ },
+
+ onWatchers(users) {
+ this.watchers = users;
+ },
+
+ surrender() {
+ os.api('games/reversi/games/surrender', {
+ gameId: this.game.id
+ });
+ },
+
+ autoplay() {
+ this.autoplaying = true;
+ this.logPos = 0;
+
+ setTimeout(() => {
+ this.logPos = 1;
+
+ let i = 1;
+ let previousLog = this.game.logs[0];
+ const tick = () => {
+ const log = this.game.logs[i];
+ const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime()
+ setTimeout(() => {
+ i++;
+ this.logPos++;
+ previousLog = log;
+
+ if (i < this.game.logs.length) {
+ tick();
+ } else {
+ this.autoplaying = false;
+ }
+ }, time);
+ };
+
+ tick();
+ }, 1000);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.xqnhankfuuilcwvhgsopeqncafzsquya {
+ text-align: center;
+
+ > .go-index {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ width: 42px;
+ height :42px;
+ }
+
+ > header {
+ padding: 8px;
+ border-bottom: dashed 1px var(--divider);
+ }
+
+ > .board {
+ width: calc(100% - 16px);
+ max-width: 500px;
+ margin: 0 auto;
+
+ $label-size: 16px;
+ $gap: 4px;
+
+ > .labels-x {
+ height: $label-size;
+ padding: 0 $label-size;
+ display: flex;
+
+ > * {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8em;
+
+ &:first-child {
+ margin-left: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-right: -(math.div($gap, 2));
+ }
+ }
+ }
+
+ > .flex {
+ display: flex;
+
+ > .labels-y {
+ width: $label-size;
+ display: flex;
+ flex-direction: column;
+
+ > * {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+
+ &:first-child {
+ margin-top: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-bottom: -(math.div($gap, 2));
+ }
+ }
+ }
+
+ > .cells {
+ flex: 1;
+ display: grid;
+ grid-gap: $gap;
+
+ > div {
+ background: transparent;
+ border-radius: 6px;
+ overflow: hidden;
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ &.empty {
+ border: solid 2px var(--divider);
+ }
+
+ &.empty.can {
+ border-color: var(--accent);
+ }
+
+ &.empty.myTurn {
+ border-color: var(--divider);
+
+ &.can {
+ border-color: var(--accent);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--accent);
+ }
+ }
+ }
+
+ &.prev {
+ box-shadow: 0 0 0 4px var(--accent);
+ }
+
+ &.isEnded {
+ border-color: var(--divider);
+ }
+
+ &.none {
+ border-color: transparent !important;
+ }
+
+ > svg, > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+ }
+ }
+
+ > .status {
+ margin: 0;
+ padding: 16px 0;
+ }
+
+ > .actions {
+ padding-bottom: 16px;
+ }
+
+ > .player {
+ padding: 0 16px 32px 16px;
+ margin: 0 auto;
+ max-width: 500px;
+
+ > span {
+ display: inline-block;
+ margin: 0 8px;
+ min-width: 70px;
+ }
+
+ > .buttons {
+ display: flex;
+
+ > * {
+ flex: 1;
+ }
+ }
+ }
+
+ > .watchers {
+ padding: 0 0 16px 0;
+
+ &:empty {
+ display: none;
+ }
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/reversi/game.setting.vue b/packages/client/src/pages/reversi/game.setting.vue
new file mode 100644
index 0000000000..e6a6661f16
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.setting.vue
@@ -0,0 +1,390 @@
+<template>
+<div class="urbixznjwwuukfsckrwzwsqzsxornqij">
+ <header><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></header>
+
+ <div>
+ <p>{{ $ts._reversi.gameSettings }}</p>
+
+ <div class="card map _panel">
+ <header>
+ <select v-model="mapName" :placeholder="$ts._reversi.chooseBoard" @change="onMapChange">
+ <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/>
+ <option :label="$ts.random" :value="null"/>
+ <optgroup v-for="c in mapCategories" :key="c" :label="c">
+ <option v-for="m in Object.values(maps).filter(m => m.category == c)" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option>
+ </optgroup>
+ </select>
+ </header>
+
+ <div>
+ <div class="random" v-if="game.map == null"><i class="fas fa-dice"></i></div>
+ <div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
+ <div v-for="(x, i) in game.map.join('')" :class="{ none: x == ' ' }" @click="onPixelClick(i, x)">
+ <i v-if="x === 'b'" class="fas fa-circle"></i>
+ <i v-if="x === 'w'" class="far fa-circle"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card _panel">
+ <header>
+ <span>{{ $ts._reversi.blackOrWhite }}</span>
+ </header>
+
+ <div>
+ <MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $ts.random }}</MkRadio>
+ <MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')">
+ <I18n :src="$ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user1"/></b>
+ </template>
+ </I18n>
+ </MkRadio>
+ <MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')">
+ <I18n :src="$ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user2"/></b>
+ </template>
+ </I18n>
+ </MkRadio>
+ </div>
+ </div>
+
+ <div class="card _panel">
+ <header>
+ <span>{{ $ts._reversi.rules }}</span>
+ </header>
+
+ <div>
+ <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ $ts._reversi.isLlotheo }}</MkSwitch>
+ <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ $ts._reversi.loopedMap }}</MkSwitch>
+ <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ $ts._reversi.canPutEverywhere }}</MkSwitch>
+ </div>
+ </div>
+
+ <div class="card form _panel" v-if="form">
+ <header>
+ <span>{{ $ts._reversi.botSettings }}</span>
+ </header>
+
+ <div>
+ <template v-for="item in form">
+ <MkSwitch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</MkSwitch>
+
+ <div class="card" v-if="item.type == 'radio'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <MkRadio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @update:modelValue="onChangeForm(item)">{{ r.label }}</MkRadio>
+ </div>
+ </div>
+
+ <div class="card" v-if="item.type == 'slider'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/>
+ </div>
+ </div>
+
+ <div class="card" v-if="item.type == 'textbox'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <input v-model="item.value" @change="onChangeForm(item)"/>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+
+ <footer class="_acrylic">
+ <p class="status">
+ <template v-if="isAccepted && isOpAccepted">{{ $ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
+ <template v-if="isAccepted && !isOpAccepted">{{ $ts._reversi.waitingForOther }}<MkEllipsis/></template>
+ <template v-if="!isAccepted && isOpAccepted">{{ $ts._reversi.waitingForMe }}</template>
+ <template v-if="!isAccepted && !isOpAccepted">{{ $ts._reversi.waitingBoth }}<MkEllipsis/></template>
+ </p>
+
+ <div class="actions">
+ <MkButton inline @click="exit">{{ $ts.cancel }}</MkButton>
+ <MkButton inline primary @click="accept" v-if="!isAccepted">{{ $ts._reversi.ready }}</MkButton>
+ <MkButton inline primary @click="cancel" v-if="isAccepted">{{ $ts._reversi.cancelReady }}</MkButton>
+ </div>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as maps from '@/scripts/games/reversi/maps';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkRadio from '@/components/form/radio.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ MkRadio,
+ },
+
+ props: {
+ initGame: {
+ type: Object,
+ require: true
+ },
+ connection: {
+ type: Object,
+ require: true
+ },
+ },
+
+ data() {
+ return {
+ game: this.initGame,
+ o: null,
+ isLlotheo: false,
+ mapName: maps.eighteight.name,
+ maps: maps,
+ form: null,
+ messages: [],
+ };
+ },
+
+ computed: {
+ mapCategories(): string[] {
+ const categories = Object.values(maps).map(x => x.category);
+ return categories.filter((item, pos) => categories.indexOf(item) == pos);
+ },
+ isAccepted(): boolean {
+ if (this.game.user1Id == this.$i.id && this.game.user1Accepted) return true;
+ if (this.game.user2Id == this.$i.id && this.game.user2Accepted) return true;
+ return false;
+ },
+ isOpAccepted(): boolean {
+ if (this.game.user1Id != this.$i.id && this.game.user1Accepted) return true;
+ if (this.game.user2Id != this.$i.id && this.game.user2Accepted) return true;
+ return false;
+ }
+ },
+
+ created() {
+ this.connection.on('changeAccepts', this.onChangeAccepts);
+ this.connection.on('updateSettings', this.onUpdateSettings);
+ this.connection.on('initForm', this.onInitForm);
+ this.connection.on('message', this.onMessage);
+
+ if (this.game.user1Id != this.$i.id && this.game.form1) this.form = this.game.form1;
+ if (this.game.user2Id != this.$i.id && this.game.form2) this.form = this.game.form2;
+ },
+
+ beforeUnmount() {
+ this.connection.off('changeAccepts', this.onChangeAccepts);
+ this.connection.off('updateSettings', this.onUpdateSettings);
+ this.connection.off('initForm', this.onInitForm);
+ this.connection.off('message', this.onMessage);
+ },
+
+ methods: {
+ exit() {
+
+ },
+
+ accept() {
+ this.connection.send('accept', {});
+ },
+
+ cancel() {
+ this.connection.send('cancelAccept', {});
+ },
+
+ onChangeAccepts(accepts) {
+ this.game.user1Accepted = accepts.user1;
+ this.game.user2Accepted = accepts.user2;
+ },
+
+ updateSettings(key: string) {
+ this.connection.send('updateSettings', {
+ key: key,
+ value: this.game[key]
+ });
+ },
+
+ onUpdateSettings({ key, value }) {
+ this.game[key] = value;
+ if (this.game.map == null) {
+ this.mapName = null;
+ } else {
+ const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join(''));
+ this.mapName = found ? found.name : '-Custom-';
+ }
+ },
+
+ onInitForm(x) {
+ if (x.userId == this.$i.id) return;
+ this.form = x.form;
+ },
+
+ onMessage(x) {
+ if (x.userId == this.$i.id) return;
+ this.messages.unshift(x.message);
+ },
+
+ onChangeForm(item) {
+ this.connection.send('updateForm', {
+ id: item.id,
+ value: item.value
+ });
+ },
+
+ onMapChange() {
+ if (this.mapName == null) {
+ this.game.map = null;
+ } else {
+ this.game.map = Object.values(maps).find(x => x.name == this.mapName).data;
+ }
+ this.updateSettings('map');
+ },
+
+ onPixelClick(pos, pixel) {
+ const x = pos % this.game.map[0].length;
+ const y = Math.floor(pos / this.game.map[0].length);
+ const newPixel =
+ pixel == ' ' ? '-' :
+ pixel == '-' ? 'b' :
+ pixel == 'b' ? 'w' :
+ ' ';
+ const line = this.game.map[y].split('');
+ line[x] = newPixel;
+ this.game.map[y] = line.join('');
+ this.updateSettings('map');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.urbixznjwwuukfsckrwzwsqzsxornqij {
+ text-align: center;
+ background: var(--bg);
+
+ > header {
+ padding: 8px;
+ border-bottom: dashed 1px #c4cdd4;
+ }
+
+ > div {
+ padding: 0 16px;
+
+ > .card {
+ margin: 0 auto 16px auto;
+
+ &.map {
+ > header {
+ > select {
+ width: 100%;
+ padding: 12px 14px;
+ background: var(--face);
+ border: 1px solid var(--inputBorder);
+ border-radius: 4px;
+ color: var(--fg);
+ cursor: pointer;
+ transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+
+ &:focus-visible,
+ &:active {
+ border-color: var(--accent);
+ }
+ }
+ }
+
+ > div {
+ > .random {
+ padding: 32px 0;
+ font-size: 64px;
+ color: var(--fg);
+ opacity: 0.7;
+ }
+
+ > .board {
+ display: grid;
+ grid-gap: 4px;
+ width: 300px;
+ height: 300px;
+ margin: 0 auto;
+ color: var(--fg);
+
+ > div {
+ background: transparent;
+ border: solid 2px var(--divider);
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.none {
+ border-color: transparent;
+ }
+ }
+ }
+ }
+ }
+
+ &.form {
+ > div {
+ > .card + .card {
+ margin-top: 16px;
+ }
+
+ input[type='range'] {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ .card {
+ max-width: 400px;
+
+ > header {
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--divider);
+ }
+
+ > div {
+ padding: 20px;
+ color: var(--fg);
+ }
+ }
+ }
+
+ > footer {
+ position: sticky;
+ bottom: 0;
+ padding: 16px;
+ border-top: solid 1px var(--divider);
+
+ > .status {
+ margin: 0 0 16px 0;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/reversi/game.vue b/packages/client/src/pages/reversi/game.vue
new file mode 100644
index 0000000000..b1ed632904
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.vue
@@ -0,0 +1,76 @@
+<template>
+<div v-if="game == null"><MkLoading/></div>
+<GameSetting v-else-if="!game.isStarted" :init-game="game" :connection="connection"/>
+<GameBoard v-else :init-game="game" :connection="connection"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import GameSetting from './game.setting.vue';
+import GameBoard from './game.board.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ GameSetting,
+ GameBoard,
+ },
+
+ props: {
+ gameId: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._reversi.reversi,
+ icon: 'fas fa-gamepad'
+ },
+ game: null,
+ connection: null,
+ };
+ },
+
+ watch: {
+ gameId() {
+ this.fetch();
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.dispose();
+ }
+ },
+
+ methods: {
+ fetch() {
+ os.api('games/reversi/games/show', {
+ gameId: this.gameId
+ }).then(game => {
+ this.game = game;
+
+ if (this.connection) {
+ this.connection.dispose();
+ }
+ this.connection = markRaw(os.stream.useChannel('gamesReversiGame', {
+ gameId: this.game.id
+ }));
+ this.connection.on('started', this.onStarted);
+ });
+ },
+
+ onStarted(game) {
+ Object.assign(this.game, game);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/reversi/index.vue b/packages/client/src/pages/reversi/index.vue
new file mode 100644
index 0000000000..1b8f1ffb71
--- /dev/null
+++ b/packages/client/src/pages/reversi/index.vue
@@ -0,0 +1,279 @@
+<template>
+<div class="bgvwxkhb" v-if="!matching">
+ <h1>Misskey {{ $ts._reversi.reversi }}</h1>
+
+ <div class="play">
+ <MkButton primary round @click="match" style="margin: var(--margin) auto 0 auto;">{{ $ts.invite }}</MkButton>
+ </div>
+
+ <div class="_section">
+ <MkFolder v-if="invitations.length > 0">
+ <template #header>{{ $ts.invitations }}</template>
+ <div class="nfcacttm">
+ <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)">
+ <MkAvatar class="avatar" :user="invitation.parent" :show-indicator="true"/>
+ <span class="name"><b><MkUserName :user="invitation.parent"/></b></span>
+ <span class="username">@{{ invitation.parent.username }}</span>
+ <MkTime :time="invitation.createdAt" class="time"/>
+ </button>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="myGames.length > 0">
+ <template #header>{{ $ts._reversi.myGames }}</template>
+ <div class="knextgwz">
+ <MkA class="game _panel" v-for="g in myGames" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id">
+ <div class="players">
+ <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/>
+ </div>
+ <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer>
+ </MkA>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="games.length > 0">
+ <template #header>{{ $ts._reversi.allGames }}</template>
+ <div class="knextgwz">
+ <MkA class="game _panel" v-for="g in games" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id">
+ <div class="players">
+ <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/>
+ </div>
+ <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer>
+ </MkA>
+ </div>
+ </MkFolder>
+ </div>
+</div>
+<div class="sazhgisb" v-else>
+ <h1>
+ <I18n :src="$ts.waitingFor" tag="span">
+ <template #x>
+ <b><MkUserName :user="matching"/></b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </h1>
+ <div class="cancel">
+ <MkButton inline round @click="cancel">{{ $ts.cancel }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import * as os from '@/os';
+import MkButton from '@/components/ui/button.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton, MkFolder,
+ },
+
+ inject: ['navHook'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._reversi.reversi,
+ icon: 'fas fa-gamepad'
+ },
+ games: [],
+ gamesFetching: true,
+ gamesMoreFetching: false,
+ myGames: [],
+ matching: null,
+ invitations: [],
+ connection: null,
+ pingClock: null,
+ };
+ },
+
+ mounted() {
+ if (this.$i) {
+ this.connection = markRaw(os.stream.useChannel('gamesReversi'));
+
+ this.connection.on('invited', this.onInvited);
+
+ this.connection.on('matched', this.onMatched);
+
+ this.pingClock = setInterval(() => {
+ if (this.matching) {
+ this.connection.send('ping', {
+ id: this.matching.id
+ });
+ }
+ }, 3000);
+
+ os.api('games/reversi/games', {
+ my: true
+ }).then(games => {
+ this.myGames = games;
+ });
+
+ os.api('games/reversi/invitations').then(invitations => {
+ this.invitations = this.invitations.concat(invitations);
+ });
+ }
+
+ os.api('games/reversi/games').then(games => {
+ this.games = games;
+ this.gamesFetching = false;
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.dispose();
+ clearInterval(this.pingClock);
+ }
+ },
+
+ methods: {
+ go(game) {
+ const url = '/games/reversi/' + game.id;
+ if (this.navHook) {
+ this.navHook(url);
+ } else {
+ this.$router.push(url);
+ }
+ },
+
+ async match() {
+ const user = await os.selectUser({ local: true });
+ if (user == null) return;
+ os.api('games/reversi/match', {
+ userId: user.id
+ }).then(res => {
+ if (res == null) {
+ this.matching = user;
+ } else {
+ this.go(res);
+ }
+ });
+ },
+
+ cancel() {
+ this.matching = null;
+ os.api('games/reversi/match/cancel');
+ },
+
+ accept(invitation) {
+ os.api('games/reversi/match', {
+ userId: invitation.parent.id
+ }).then(game => {
+ if (game) {
+ this.go(game);
+ }
+ });
+ },
+
+ onMatched(game) {
+ this.go(game);
+ },
+
+ onInvited(invite) {
+ this.invitations.unshift(invite);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bgvwxkhb {
+ > h1 {
+ margin: 0;
+ padding: 24px;
+ text-align: center;
+ font-size: 1.5em;
+ background: linear-gradient(0deg, #43c583, #438881);
+ color: #fff;
+ }
+
+ > .play {
+ text-align: center;
+ }
+}
+
+.sazhgisb {
+ text-align: center;
+}
+
+.nfcacttm {
+ > .invitation {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 16px;
+ line-height: 32px;
+ text-align: left;
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+ margin-right: 8px;
+ }
+
+ > .name {
+ margin-right: 8px;
+ }
+
+ > .username {
+ margin-right: 8px;
+ opacity: 0.7;
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+}
+
+.knextgwz {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+
+ > .game {
+ > .players {
+ text-align: center;
+ padding: 16px;
+ line-height: 32px;
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+
+ &:first-child {
+ margin-right: 8px;
+ }
+
+ &:last-child {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > footer {
+ display: flex;
+ align-items: baseline;
+ border-top: solid 0.5px var(--divider);
+ padding: 6px 8px;
+ font-size: 0.9em;
+
+ > .state {
+ &.playing {
+ color: var(--accent);
+ }
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/room/preview.vue b/packages/client/src/pages/room/preview.vue
new file mode 100644
index 0000000000..b0e600d4fb
--- /dev/null
+++ b/packages/client/src/pages/room/preview.vue
@@ -0,0 +1,107 @@
+<template>
+<canvas width="224" height="128"></canvas>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as THREE from 'three';
+import * as os from '@/os';
+
+export default defineComponent({
+ data() {
+ return {
+ selected: null,
+ objectHeight: 0,
+ orbitRadius: 5
+ };
+ },
+
+ mounted() {
+ const canvas = this.$el;
+
+ const width = canvas.width;
+ const height = canvas.height;
+
+ const scene = new THREE.Scene();
+
+ const renderer = new THREE.WebGLRenderer({
+ canvas: canvas,
+ antialias: true,
+ alpha: false
+ });
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.setSize(width, height);
+ renderer.setClearColor(0x000000);
+ renderer.autoClear = false;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.cullFace = THREE.CullFaceBack;
+
+ const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100);
+ camera.zoom = 10;
+ camera.position.x = 0;
+ camera.position.y = 2;
+ camera.position.z = 0;
+ camera.updateProjectionMatrix();
+ scene.add(camera);
+
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1);
+ ambientLight.castShadow = false;
+ scene.add(ambientLight);
+
+ const light = new THREE.PointLight(0xffffff, 1, 100);
+ light.position.set(3, 3, 3);
+ scene.add(light);
+
+ const grid = new THREE.GridHelper(5, 16, 0x444444, 0x222222);
+ scene.add(grid);
+
+ const render = () => {
+ const timer = Date.now() * 0.0004;
+ requestAnimationFrame(render);
+
+ camera.position.y = Math.sin(Math.PI / 6) * this.orbitRadius; // Math.PI / 6 => 30deg
+ camera.position.z = Math.cos(timer) * this.orbitRadius;
+ camera.position.x = Math.sin(timer) * this.orbitRadius;
+ camera.lookAt(new THREE.Vector3(0, this.objectHeight / 2, 0));
+ renderer.render(scene, camera);
+ };
+
+ this.selected = selected => {
+ const obj = selected.clone();
+
+ // Remove current object
+ const current = scene.getObjectByName('obj');
+ if (current != null) {
+ scene.remove(current);
+ }
+
+ // Add new object
+ obj.name = 'obj';
+ obj.position.x = 0;
+ obj.position.y = 0;
+ obj.position.z = 0;
+ obj.rotation.x = 0;
+ obj.rotation.y = 0;
+ obj.rotation.z = 0;
+ obj.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ child.material = child.material.clone();
+ return child.material.emissive.setHex(0x000000);
+ }
+ });
+ const objectBoundingBox = new THREE.Box3().setFromObject(obj);
+ this.objectHeight = objectBoundingBox.max.y - objectBoundingBox.min.y;
+
+ const objectWidth = objectBoundingBox.max.x - objectBoundingBox.min.x;
+ const objectDepth = objectBoundingBox.max.z - objectBoundingBox.min.z;
+
+ const horizontal = Math.hypot(objectWidth, objectDepth) / camera.aspect;
+ this.orbitRadius = Math.max(horizontal, this.objectHeight) * camera.zoom * 0.625 / Math.tan(camera.fov * 0.5 * (Math.PI / 180));
+
+ scene.add(obj);
+ };
+
+ render();
+ },
+});
+</script>
diff --git a/packages/client/src/pages/room/room.vue b/packages/client/src/pages/room/room.vue
new file mode 100644
index 0000000000..1671bcd587
--- /dev/null
+++ b/packages/client/src/pages/room/room.vue
@@ -0,0 +1,285 @@
+<template>
+<div class="hveuntkp">
+ <div class="controller _section" v-if="objectSelected">
+ <div class="_content">
+ <p class="name">{{ selectedFurnitureName }}</p>
+ <XPreview ref="preview"/>
+ <template v-if="selectedFurnitureInfo.props">
+ <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k">
+ <p>{{ k }}</p>
+ <template v-if="selectedFurnitureInfo.props[k] === 'image'">
+ <MkButton @click="chooseImage(k, $event)">{{ $ts._rooms.chooseImage }}</MkButton>
+ </template>
+ <template v-else-if="selectedFurnitureInfo.props[k] === 'color'">
+ <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/>
+ </template>
+ </div>
+ </template>
+ </div>
+ <div class="_content">
+ <MkButton inline @click="translate()" :primary="isTranslateMode"><i class="fas fa-arrows-alt"></i> {{ $ts._rooms.translate }}</MkButton>
+ <MkButton inline @click="rotate()" :primary="isRotateMode"><i class="fas fa-undo"></i> {{ $ts._rooms.rotate }}</MkButton>
+ <MkButton inline v-if="isTranslateMode || isRotateMode" @click="exit()"><i class="fas fa-ban"></i> {{ $ts._rooms.exit }}</MkButton>
+ </div>
+ <div class="_content">
+ <MkButton @click="remove()"><i class="fas fa-trash-alt"></i> {{ $ts._rooms.remove }}</MkButton>
+ </div>
+ </div>
+
+ <div class="menu _section" v-if="isMyRoom">
+ <div class="_content">
+ <MkButton @click="add()"><i class="fas fa-box-open"></i> {{ $ts._rooms.addFurniture }}</MkButton>
+ </div>
+ <div class="_content">
+ <MkSelect :model-value="roomType" @update:modelValue="updateRoomType($event)">
+ <template #label>{{ $ts._rooms.roomType }}</template>
+ <option value="default">{{ $ts._rooms._roomType.default }}</option>
+ <option value="washitsu">{{ $ts._rooms._roomType.washitsu }}</option>
+ </MkSelect>
+ <label v-if="roomType === 'default'">
+ <span>{{ $ts._rooms.carpetColor }}</span>
+ <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/>
+ </label>
+ </div>
+ <div class="_content">
+ <MkButton inline :disabled="!changed" primary @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="clear()"><i class="fas fa-broom"></i> {{ $ts._rooms.clear }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import { Room } from '@/scripts/room/room';
+import * as Acct from 'misskey-js/built/acct';
+import XPreview from './preview.vue';
+const storeItems = require('@/scripts/room/furnitures.json5');
+import { query as urlQuery } from '@/scripts/url';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+let room: Room;
+
+export default defineComponent({
+ components: {
+ XPreview,
+ MkButton,
+ MkSelect,
+ },
+
+ props: {
+ acct: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.user ? {
+ title: this.$ts.room,
+ avatar: this.user,
+ } : null),
+ user: null,
+ objectSelected: false,
+ selectedFurnitureName: null,
+ selectedFurnitureInfo: null,
+ selectedFurnitureProps: null,
+ roomType: null,
+ carpetColor: null,
+ isTranslateMode: false,
+ isRotateMode: false,
+ isMyRoom: false,
+ changed: false,
+ };
+ },
+
+ async mounted() {
+ window.addEventListener('beforeunload', this.beforeunload);
+
+ this.user = await os.api('users/show', {
+ ...Acct.parse(this.acct)
+ });
+
+ this.isMyRoom = this.$i && (this.$i.id === this.user.id);
+
+ const roomInfo = await os.api('room/show', {
+ userId: this.user.id
+ });
+
+ this.roomType = roomInfo.roomType;
+ this.carpetColor = roomInfo.carpetColor;
+
+ room = new Room(this.user, this.isMyRoom, roomInfo, this.$el, {
+ graphicsQuality: ColdDeviceStorage.get('roomGraphicsQuality'),
+ onChangeSelect: obj => {
+ this.objectSelected = obj != null;
+ if (obj) {
+ const f = room.findFurnitureById(obj.name);
+ this.selectedFurnitureName = this.$t('_rooms._furnitures.' + f.type);
+ this.selectedFurnitureInfo = storeItems.find(x => x.id === f.type);
+ this.selectedFurnitureProps = f.props
+ ? JSON.parse(JSON.stringify(f.props)) // Disable reactivity
+ : null;
+ this.$nextTick(() => {
+ this.$refs.preview.selected(obj);
+ });
+ }
+ },
+ useOrthographicCamera: ColdDeviceStorage.get('roomUseOrthographicCamera'),
+ });
+ },
+
+ beforeRouteLeave(to, from, next) {
+ if (this.changed) {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) {
+ next(false);
+ } else {
+ next();
+ }
+ });
+ } else {
+ next();
+ }
+ },
+
+ beforeUnmount() {
+ room.destroy();
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async add() {
+ const { canceled, result: id } = await os.dialog({
+ type: null,
+ title: this.$ts._rooms.addFurniture,
+ select: {
+ items: storeItems.map(item => ({
+ value: item.id, text: this.$t('_rooms._furnitures.' + item.id)
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ room.addFurniture(id);
+ this.changed = true;
+ },
+
+ remove() {
+ this.isTranslateMode = false;
+ this.isRotateMode = false;
+ room.removeFurniture();
+ this.changed = true;
+ },
+
+ save() {
+ os.api('room/update', {
+ room: room.getRoomInfo()
+ }).then(() => {
+ this.changed = false;
+ os.success();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ clear() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts._rooms.clearConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ room.removeAllFurnitures();
+ this.changed = true;
+ });
+ },
+
+ chooseImage(key, e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`);
+ this.$refs.preview.selected(room.getSelectedObject());
+ this.changed = true;
+ });
+ },
+
+ updateColor(key, ev) {
+ room.updateProp(key, ev.target.value);
+ this.$refs.preview.selected(room.getSelectedObject());
+ this.changed = true;
+ },
+
+ updateCarpetColor(ev) {
+ room.updateCarpetColor(ev.target.value);
+ this.carpetColor = ev.target.value;
+ this.changed = true;
+ },
+
+ updateRoomType(type) {
+ room.changeRoomType(type);
+ this.roomType = type;
+ this.changed = true;
+ },
+
+ translate() {
+ if (this.isTranslateMode) {
+ this.exit();
+ } else {
+ this.isRotateMode = false;
+ this.isTranslateMode = true;
+ room.enterTransformMode('translate');
+ }
+ this.changed = true;
+ },
+
+ rotate() {
+ if (this.isRotateMode) {
+ this.exit();
+ } else {
+ this.isTranslateMode = false;
+ this.isRotateMode = true;
+ room.enterTransformMode('rotate');
+ }
+ this.changed = true;
+ },
+
+ exit() {
+ this.isTranslateMode = false;
+ this.isRotateMode = false;
+ room.exitTransformMode();
+ this.changed = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hveuntkp {
+ position: relative;
+ min-height: 500px;
+
+ > ::v-deep(canvas) {
+ display: block;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
new file mode 100644
index 0000000000..c26658cbc4
--- /dev/null
+++ b/packages/client/src/pages/scratchpad.vue
@@ -0,0 +1,149 @@
+<template>
+<div class="iltifgqe">
+ <div class="editor _panel _gap">
+ <PrismEditor class="_code code" v-model="code" :highlight="highlighter" :line-numbers="false"/>
+ <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><i class="fas fa-play"></i></MkButton>
+ </div>
+
+ <MkContainer :foldable="true" class="_gap">
+ <template #header>{{ $ts.output }}</template>
+ <div class="bepmlvbi">
+ <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div>
+ </div>
+ </MkContainer>
+
+ <div class="_gap">
+ {{ $ts.scratchpadDescription }}
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } 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, values } from '@syuilo/aiscript';
+import MkContainer from '@/components/ui/container.vue';
+import MkButton from '@/components/ui/button.vue';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ MkButton,
+ PrismEditor,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.scratchpad,
+ icon: 'fas fa-terminal',
+ },
+ code: '',
+ logs: [],
+ }
+ },
+
+ watch: {
+ code() {
+ localStorage.setItem('scratchpad', this.code);
+ }
+ },
+
+ created() {
+ const saved = localStorage.getItem('scratchpad');
+ if (saved) {
+ this.code = saved;
+ }
+ },
+
+ methods: {
+ async run() {
+ this.logs = [];
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'scratchpad',
+ token: this.$i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.dialog({
+ title: q,
+ input: {}
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ this.logs.push({
+ id: Math.random(),
+ text: value.type === 'str' ? value.value : utils.valToString(value),
+ print: true
+ });
+ },
+ log: (type, params) => {
+ switch (type) {
+ case 'end': this.logs.push({
+ id: Math.random(),
+ text: utils.valToString(params.val, true),
+ print: false
+ }); break;
+ default: break;
+ }
+ }
+ });
+
+ let ast;
+ try {
+ ast = parse(this.code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ }
+ },
+
+ highlighter(code) {
+ return highlight(code, languages.js, 'javascript');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iltifgqe {
+ padding: 16px;
+
+ > .editor {
+ position: relative;
+ }
+}
+
+.bepmlvbi {
+ padding: 16px;
+
+ > .log {
+ &:not(.print) {
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
new file mode 100644
index 0000000000..c7da3fe1c1
--- /dev/null
+++ b/packages/client/src/pages/search.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_section">
+ <div class="_content">
+ <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: computed(() => this.$t('searchWith', { q: this.$route.query.q })),
+ icon: 'fas fa-search',
+ },
+ pagination: {
+ endpoint: 'notes/search',
+ limit: 10,
+ params: () => ({
+ query: this.$route.query.q,
+ channelId: this.$route.query.channel,
+ })
+ },
+ };
+ },
+
+ watch: {
+ $route() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue
new file mode 100644
index 0000000000..dce217559a
--- /dev/null
+++ b/packages/client/src/pages/settings/2fa.vue
@@ -0,0 +1,247 @@
+<template>
+<section class="_card">
+ <div class="_title"><i class="fas fa-lock"></i> {{ $ts.twoStepAuthentication }}</div>
+ <div class="_content">
+ <MkButton v-if="!data && !$i.twoFactorEnabled" @click="register">{{ $ts._2fa.registerDevice }}</MkButton>
+ <template v-if="$i.twoFactorEnabled">
+ <p>{{ $ts._2fa.alreadyRegistered }}</p>
+ <MkButton @click="unregister">{{ $ts.unregister }}</MkButton>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ $ts.securityKey }}</h2>
+ <p>{{ $ts._2fa.securityKeyInfo }}</p>
+ <div class="key-list">
+ <div class="key" v-for="key in $i.securityKeysList">
+ <h3>{{ key.name }}</h3>
+ <div class="last-used">{{ $ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
+ <MkButton @click="unregisterKey(key)">{{ $ts.unregister }}</MkButton>
+ </div>
+ </div>
+
+ <MkSwitch v-model="usePasswordLessLogin" @update:modelValue="updatePasswordLessLogin" v-if="$i.securityKeysList.length > 0">{{ $ts.passwordLessLogin }}</MkSwitch>
+
+ <MkInfo warn v-if="registration && registration.error">{{ $ts.error }} {{ registration.error }}</MkInfo>
+ <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $ts._2fa.registerKey }}</MkButton>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ $ts.tapSecurityKey }}
+ <i v-if="registration.saving && registration.stage == 0" class="fas fa-spinner fa-pulse fa-fw"></i>
+ </li>
+ <li v-if="registration.stage >= 1">
+ <MkForm :disabled="registration.stage != 1 || registration.saving">
+ <MkInput v-model="keyName" :max="30">
+ <template #label>{{ $ts.securityKeyName }}</template>
+ </MkInput>
+ <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $ts.registerSecurityKey }}</MkButton>
+ <i v-if="registration.saving && registration.stage == 1" class="fas fa-spinner fa-pulse fa-fw"></i>
+ </MkForm>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="data && !$i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <I18n :src="$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>{{ $ts._2fa.step2 }}<br><img :src="data.qr"></li>
+ <li>{{ $ts._2fa.step3 }}<br>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ $ts.token }}</template></MkInput>
+ <MkButton primary @click="submit">{{ $ts.done }}</MkButton>
+ </li>
+ </ol>
+ <MkInfo>{{ $ts._2fa.step4 }}</MkInfo>
+ </div>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { hostname } from '@/config';
+import { byteify, hexify, stringify } from '@/scripts/2fa';
+import MkButton from '@/components/ui/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkButton, MkInfo, MkInput, MkSwitch
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.twoStepAuthentication,
+ icon: 'fas fa-lock'
+ },
+ data: null,
+ supportsCredentials: !!navigator.credentials,
+ usePasswordLessLogin: this.$i.usePasswordLessLogin,
+ registration: null,
+ keyName: '',
+ token: null,
+ };
+ },
+
+ methods: {
+ register() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register', {
+ password: password
+ }).then(data => {
+ this.data = data;
+ });
+ });
+ },
+
+ unregister() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/unregister', {
+ password: password
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ this.$i.twoFactorEnabled = false;
+ });
+ });
+ },
+
+ submit() {
+ os.api('i/2fa/done', {
+ token: this.token
+ }).then(() => {
+ os.success();
+ this.$i.twoFactorEnabled = true;
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ registerKey() {
+ this.registration.saving = true;
+ os.api('i/2fa/key-done', {
+ password: this.registration.password,
+ name: this.keyName,
+ challengeId: this.registration.challengeId,
+ // we convert each 16 bits to a string to serialise
+ clientDataJSON: stringify(this.registration.credential.response.clientDataJSON),
+ attestationObject: hexify(this.registration.credential.response.attestationObject)
+ }).then(key => {
+ this.registration = null;
+ key.lastUsed = new Date();
+ os.success();
+ })
+ },
+
+ unregisterKey(key) {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return os.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ });
+ });
+ },
+
+ addSecurityKey() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register-key', {
+ password
+ }).then(registration => {
+ this.registration = {
+ password,
+ challengeId: registration.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: byteify(registration.challenge, 'base64'),
+ rp: {
+ id: hostname,
+ name: 'Misskey'
+ },
+ user: {
+ id: byteify(this.$i.id, 'ascii'),
+ name: this.$i.username,
+ displayName: this.$i.name,
+ },
+ pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
+ timeout: 60000,
+ attestation: 'direct'
+ },
+ saving: true
+ };
+ return navigator.credentials.create({
+ publicKey: this.registration.publicKeyOptions
+ });
+ }).then(credential => {
+ this.registration.credential = credential;
+ this.registration.saving = false;
+ this.registration.stage = 1;
+ }).catch(err => {
+ console.warn('Error while registering?', err);
+ this.registration.error = err.message;
+ this.registration.stage = -1;
+ });
+ });
+ },
+
+ updatePasswordLessLogin() {
+ os.api('i/2fa/password-less', {
+ value: !!this.usePasswordLessLogin
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
new file mode 100644
index 0000000000..f3d5e2f2c3
--- /dev/null
+++ b/packages/client/src/pages/settings/account-info.vue
@@ -0,0 +1,185 @@
+<template>
+<FormBase>
+ <FormKeyValueView>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ $i.id }}</span></template>
+ </FormKeyValueView>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.registeredDate }}</template>
+ <template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="stats">
+ <template #label>{{ $ts.statistics }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.notesCount }}</template>
+ <template #value>{{ number(stats.notesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.repliesCount }}</template>
+ <template #value>{{ number(stats.repliesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.renotesCount }}</template>
+ <template #value>{{ number(stats.renotesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.repliedCount }}</template>
+ <template #value>{{ number(stats.repliedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.renotedCount }}</template>
+ <template #value>{{ number(stats.renotedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pollVotesCount }}</template>
+ <template #value>{{ number(stats.pollVotesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pollVotedCount }}</template>
+ <template #value>{{ number(stats.pollVotedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.sentReactionsCount }}</template>
+ <template #value>{{ number(stats.sentReactionsCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.receivedReactionsCount }}</template>
+ <template #value>{{ number(stats.receivedReactionsCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.noteFavoritesCount }}</template>
+ <template #value>{{ number(stats.noteFavoritesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }}</template>
+ <template #value>{{ number(stats.followingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }} ({{ $ts.local }})</template>
+ <template #value>{{ number(stats.localFollowingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }} ({{ $ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }}</template>
+ <template #value>{{ number(stats.followersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }} ({{ $ts.local }})</template>
+ <template #value>{{ number(stats.localFollowersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }} ({{ $ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pageLikesCount }}</template>
+ <template #value>{{ number(stats.pageLikesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pageLikedCount }}</template>
+ <template #value>{{ number(stats.pageLikedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.driveFilesCount }}</template>
+ <template #value>{{ number(stats.driveFilesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.driveUsage }}</template>
+ <template #value>{{ bytes(stats.driveUsage) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.reversiCount }}</template>
+ <template #value>{{ number(stats.reversiCount) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <template #label>{{ $ts.other }}</template>
+ <FormKeyValueView>
+ <template #key>emailVerified</template>
+ <template #value>{{ $i.emailVerified ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>twoFactorEnabled</template>
+ <template #value>{{ $i.twoFactorEnabled ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>securityKeys</template>
+ <template #value>{{ $i.securityKeys ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>usePasswordLessLogin</template>
+ <template #value>{{ $i.usePasswordLessLogin ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>isModerator</template>
+ <template #value>{{ $i.isModerator ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>isAdmin</template>
+ <template #value>{{ $i.isAdmin ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.accountInfo,
+ icon: 'fas fa-info-circle'
+ },
+ stats: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('users/stats', {
+ userId: this.$i.id
+ }).then(stats => {
+ this.stats = stats;
+ });
+ },
+
+ methods: {
+ number,
+ bytes,
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
new file mode 100644
index 0000000000..94a3c9483d
--- /dev/null
+++ b/packages/client/src/pages/settings/accounts.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormButton @click="addAccount" primary><i class="fas fa-plus"></i> {{ $ts.addAccount }}</FormButton>
+
+ <div class="_debobigegoItem _button" v-for="account in accounts" :key="account.id" @click="menu(account, $event)">
+ <div class="_debobigegoPanel lcjjdxlm">
+ <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>
+ </div>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { getAccounts, addAccount, login } from '@/account';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSuspense,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.accounts,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ },
+ storedAccounts: getAccounts().then(accounts => accounts.filter(x => x.id !== this.$i.id)),
+ accounts: null,
+ init: async () => os.api('users/show', {
+ userIds: (await this.storedAccounts).map(x => x.id)
+ }).then(accounts => {
+ this.accounts = accounts;
+ }),
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ menu(account, ev) {
+ os.popupMenu([{
+ text: this.$ts.switch,
+ icon: 'fas fa-exchange-alt',
+ action: () => this.switchAccount(account),
+ }, {
+ text: this.$ts.remove,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => this.removeAccount(account),
+ }], ev.currentTarget || ev.target);
+ },
+
+ addAccount(ev) {
+ os.popupMenu([{
+ text: this.$ts.existingAccount,
+ action: () => { this.addExistingAccount(); },
+ }, {
+ text: this.$ts.createAccount,
+ action: () => { this.createAccount(); },
+ }], ev.currentTarget || ev.target);
+ },
+
+ addExistingAccount() {
+ os.popup(import('@/components/signin-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ os.success();
+ },
+ }, 'closed');
+ },
+
+ createAccount() {
+ os.popup(import('@/components/signup-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ this.switchAccountWithToken(res.i);
+ },
+ }, 'closed');
+ },
+
+ async switchAccount(account: any) {
+ const storedAccounts = await getAccounts();
+ const token = storedAccounts.find(x => x.id === account.id).token;
+ this.switchAccountWithToken(token);
+ },
+
+ switchAccountWithToken(token: string) {
+ login(token);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcjjdxlm {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+
+ > .body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
new file mode 100644
index 0000000000..1def0189ec
--- /dev/null
+++ b/packages/client/src/pages/settings/api.vue
@@ -0,0 +1,65 @@
+<template>
+<FormBase>
+ <FormButton @click="generateToken" primary>{{ $ts.generateAccessToken }}</FormButton>
+ <FormLink to="/settings/apps">{{ $ts.manageAccessTokens }}</FormLink>
+ <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'API',
+ icon: 'fas fa-key',
+ bg: 'var(--bg)',
+ },
+ isDesktop: window.innerWidth >= 1100,
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ generateToken() {
+ os.popup(import('@/components/token-generate-window.vue'), {}, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ os.dialog({
+ type: 'success',
+ title: this.$ts.token,
+ text: token
+ });
+ },
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
new file mode 100644
index 0000000000..6eec80d805
--- /dev/null
+++ b/packages/client/src/pages/settings/apps.vue
@@ -0,0 +1,113 @@
+<template>
+<FormBase>
+ <FormPagination :pagination="pagination" ref="list">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.nothing }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div class="_debobigegoPanel bfomjevm" v-for="token in items" :key="token.id">
+ <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/>
+ <div class="body">
+ <div class="name">{{ token.name }}</div>
+ <div class="description">{{ token.description }}</div>
+ <div class="_keyValue">
+ <div>{{ $ts.installedDate }}:</div>
+ <div><MkTime :time="token.createdAt"/></div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.lastUsedDate }}:</div>
+ <div><MkTime :time="token.lastUsedAt"/></div>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button>
+ </div>
+ <details>
+ <summary>{{ $ts.details }}</summary>
+ <ul>
+ <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </details>
+ </div>
+ </div>
+ </template>
+ </FormPagination>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormPagination from '@/components/debobigego/pagination.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.installedApps,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/apps',
+ limit: 100,
+ params: {
+ sort: '+lastUsedAt'
+ }
+ },
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ revoke(token) {
+ os.api('i/revoke-token', { tokenId: token.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bfomjevm {
+ display: flex;
+ padding: 16px;
+
+ > .icon {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
new file mode 100644
index 0000000000..8c878fb084
--- /dev/null
+++ b/packages/client/src/pages/settings/custom-css.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts.customCssWarn }}</FormInfo>
+
+ <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;">
+ <span>{{ $ts.local }}</span>
+ </FormTextarea>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.customCss,
+ icon: 'fas fa-code',
+ bg: 'var(--bg)',
+ },
+ localCustomCss: localStorage.getItem('customCss')
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ this.$watch('localCustomCss', this.apply);
+ },
+
+ methods: {
+ async apply() {
+ localStorage.setItem('customCss', this.localCustomCss);
+
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
new file mode 100644
index 0000000000..a96c6cd685
--- /dev/null
+++ b/packages/client/src/pages/settings/deck.vue
@@ -0,0 +1,107 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <template #label>{{ $ts.defaultNavigationBehaviour }}</template>
+ <FormSwitch v-model="navWindow">{{ $ts.openInWindow }}</FormSwitch>
+ </FormGroup>
+
+ <FormSwitch v-model="alwaysShowMainColumn">{{ $ts._deck.alwaysShowMainColumn }}</FormSwitch>
+
+ <FormRadios v-model="columnAlign">
+ <template #desc>{{ $ts._deck.columnAlign }}</template>
+ <option value="left">{{ $ts.left }}</option>
+ <option value="center">{{ $ts.center }}</option>
+ </FormRadios>
+
+ <FormRadios v-model="columnHeaderHeight">
+ <template #desc>{{ $ts._deck.columnHeaderHeight }}</template>
+ <option :value="42">{{ $ts.narrow }}</option>
+ <option :value="45">{{ $ts.medium }}</option>
+ <option :value="48">{{ $ts.wide }}</option>
+ </FormRadios>
+
+ <FormInput v-model="columnMargin" type="number">
+ <span>{{ $ts._deck.columnMargin }}</span>
+ <template #suffix>px</template>
+ </FormInput>
+
+ <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { deckStore } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormLink,
+ FormInput,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.deck,
+ icon: 'fas fa-columns',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ computed: {
+ navWindow: deckStore.makeGetterSetter('navWindow'),
+ alwaysShowMainColumn: deckStore.makeGetterSetter('alwaysShowMainColumn'),
+ columnAlign: deckStore.makeGetterSetter('columnAlign'),
+ columnMargin: deckStore.makeGetterSetter('columnMargin'),
+ columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'),
+ profile: deckStore.makeGetterSetter('profile'),
+ },
+
+ watch: {
+ async navWindow() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async setProfile() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts._deck.profile,
+ input: {
+ allowEmpty: false
+ }
+ });
+ if (canceled) return;
+ this.profile = name;
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
new file mode 100644
index 0000000000..018f7c795e
--- /dev/null
+++ b/packages/client/src/pages/settings/delete-account.vue
@@ -0,0 +1,68 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts._accountDelete.mayTakeTime }}</FormInfo>
+ <FormInfo>{{ $ts._accountDelete.sendEmail }}</FormInfo>
+ <FormButton @click="deleteAccount" danger v-if="!$i.isDeleted">{{ $ts._accountDelete.requestAccountDelete }}</FormButton>
+ <FormButton disabled v-else>{{ $ts._accountDelete.inProgress }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { debug } from '@/config';
+import { signout } from '@/account';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormGroup,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._accountDelete.accountDelete,
+ icon: 'fas fa-exclamation-triangle',
+ bg: 'var(--bg)',
+ },
+ debug,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async deleteAccount() {
+ const { canceled, result: password } = await os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('i/delete-account', {
+ password: password
+ });
+
+ await os.dialog({
+ title: this.$ts._accountDelete.started,
+ });
+
+ signout();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
new file mode 100644
index 0000000000..ed5282e23d
--- /dev/null
+++ b/packages/client/src/pages/settings/drive.vue
@@ -0,0 +1,147 @@
+<template>
+<FormBase class="">
+ <FormGroup v-if="!fetching">
+ <template #label>{{ $ts.usageAmount }}</template>
+ <div class="_debobigegoItem uawsfosz">
+ <div class="_debobigegoPanel">
+ <div class="meter"><div :style="meterStyle"></div></div>
+ </div>
+ </div>
+ <FormKeyValueView>
+ <template #key>{{ $ts.capacity }}</template>
+ <template #value>{{ bytes(capacity, 1) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.inUse }}</template>
+ <template #value>{{ bytes(usage, 1) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.statistics }}</div>
+ <div class="_debobigegoPanel">
+ <div ref="chart"></div>
+ </div>
+ </div>
+
+ <FormButton :center="false" @click="chooseUploadFolder()" primary>
+ {{ $ts.uploadFolder }}
+ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
+ <template #suffixIcon><i class="fas fa-folder-open"></i></template>
+ </FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import FormButton from '@/components/debobigego/button.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+
+// TODO: render chart
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.drive,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ fetching: true,
+ usage: null,
+ capacity: null,
+ uploadFolder: null,
+ }
+ },
+
+ computed: {
+ meterStyle(): any {
+ return {
+ width: `${this.usage / this.capacity * 100}%`,
+ background: tinycolor({
+ h: 180 - (this.usage / this.capacity * 180),
+ s: 0.7,
+ l: 0.5
+ })
+ };
+ }
+ },
+
+ async created() {
+ os.api('drive').then(info => {
+ this.capacity = info.capacity;
+ this.usage = info.usage;
+ this.fetching = false;
+ this.$nextTick(() => {
+ this.renderChart();
+ });
+ });
+
+ if (this.$store.state.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.uploadFolder
+ });
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ chooseUploadFolder() {
+ os.selectDriveFolder(false).then(async folder => {
+ this.$store.set('uploadFolder', folder ? folder.id : null);
+ os.success();
+ if (this.$store.state.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.uploadFolder
+ });
+ } else {
+ this.uploadFolder = null;
+ }
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.uawsfosz {
+ > div {
+ padding: 24px;
+
+ > .meter {
+ $size: 12px;
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: math.div($size, 2);
+ overflow: hidden;
+
+ > div {
+ height: $size;
+ border-radius: math.div($size, 2);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/email-address.vue b/packages/client/src/pages/settings/email-address.vue
new file mode 100644
index 0000000000..476d0c0e17
--- /dev/null
+++ b/packages/client/src/pages/settings/email-address.vue
@@ -0,0 +1,70 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormInput v-model="emailAddress" type="email">
+ {{ $ts.emailAddress }}
+ <template #desc v-if="$i.email && !$i.emailVerified">{{ $ts.verificationEmailSent }}</template>
+ <template #desc v-else-if="emailAddress === $i.email && $i.emailVerified">{{ $ts.emailVerified }}</template>
+ </FormInput>
+ </FormGroup>
+ <FormButton @click="save" primary>{{ $ts.save }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormInput,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailAddress,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ emailAddress: null,
+ code: null,
+ }
+ },
+
+ created() {
+ this.emailAddress = this.$i.email;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/update-email', {
+ password: password,
+ email: this.emailAddress,
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/email-notification.vue b/packages/client/src/pages/settings/email-notification.vue
new file mode 100644
index 0000000000..c1735a0728
--- /dev/null
+++ b/packages/client/src/pages/settings/email-notification.vue
@@ -0,0 +1,91 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormSwitch v-model="mention">
+ {{ $ts._notification._types.mention }}
+ </FormSwitch>
+ <FormSwitch v-model="reply">
+ {{ $ts._notification._types.reply }}
+ </FormSwitch>
+ <FormSwitch v-model="quote">
+ {{ $ts._notification._types.quote }}
+ </FormSwitch>
+ <FormSwitch v-model="follow">
+ {{ $ts._notification._types.follow }}
+ </FormSwitch>
+ <FormSwitch v-model="receiveFollowRequest">
+ {{ $ts._notification._types.receiveFollowRequest }}
+ </FormSwitch>
+ <FormSwitch v-model="groupInvited">
+ {{ $ts._notification._types.groupInvited }}
+ </FormSwitch>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSwitch,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailNotification,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+
+ mention: this.$i.emailNotificationTypes.includes('mention'),
+ reply: this.$i.emailNotificationTypes.includes('reply'),
+ quote: this.$i.emailNotificationTypes.includes('quote'),
+ follow: this.$i.emailNotificationTypes.includes('follow'),
+ receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'),
+ groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'),
+ }
+ },
+
+ created() {
+ this.$watch('mention', this.save);
+ this.$watch('reply', this.save);
+ this.$watch('quote', this.save);
+ this.$watch('follow', this.save);
+ this.$watch('receiveFollowRequest', this.save);
+ this.$watch('groupInvited', this.save);
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.api('i/update', {
+ emailNotificationTypes: [
+ ...[this.mention ? 'mention' : null],
+ ...[this.reply ? 'reply' : null],
+ ...[this.quote ? 'quote' : null],
+ ...[this.follow ? 'follow' : null],
+ ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null],
+ ...[this.groupInvited ? 'groupInvited' : null],
+ ].filter(x => x != null)
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
new file mode 100644
index 0000000000..d1dda20f00
--- /dev/null
+++ b/packages/client/src/pages/settings/email.vue
@@ -0,0 +1,66 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <template #label>{{ $ts.emailAddress }}</template>
+ <FormLink to="/settings/email/address">
+ <template v-if="$i.email && !$i.emailVerified" #icon><i class="fas fa-exclamation-triangle" style="color: var(--warn);"></i></template>
+ <template v-else-if="$i.email && $i.emailVerified" #icon><i class="fas fa-check" style="color: var(--success);"></i></template>
+ {{ $i.email || $ts.notSet }}
+ </FormLink>
+ </FormGroup>
+
+ <FormLink to="/settings/email/notification">
+ <template #icon><i class="fas fa-bell"></i></template>
+ {{ $ts.emailNotification }}
+ </FormLink>
+
+ <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail">
+ {{ $ts.receiveAnnouncementFromInstance }}
+ </FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormSwitch,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.email,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ onChangeReceiveAnnouncementEmail(v) {
+ os.api('i/update', {
+ receiveAnnouncementEmail: v
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/experimental-features.vue b/packages/client/src/pages/settings/experimental-features.vue
new file mode 100644
index 0000000000..5a7bcb3b41
--- /dev/null
+++ b/packages/client/src/pages/settings/experimental-features.vue
@@ -0,0 +1,52 @@
+<template>
+<FormBase>
+ <FormButton @click="error()">error test</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.experimentalFeatures,
+ icon: 'fas fa-flask'
+ },
+ stats: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ error() {
+ throw new Error('Test error');
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
new file mode 100644
index 0000000000..8e3dcc3e41
--- /dev/null
+++ b/packages/client/src/pages/settings/general.vue
@@ -0,0 +1,223 @@
+<template>
+<FormBase>
+ <FormSwitch v-model="showFixedPostForm">{{ $ts.showFixedPostForm }}</FormSwitch>
+
+ <FormSelect v-model="lang">
+ <template #label>{{ $ts.uiLanguage }}</template>
+ <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+ <template #caption>
+ <I18n :src="$ts.i18nInfo" tag="span">
+ <template #link>
+ <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
+ </template>
+ </I18n>
+ </template>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.behavior }}</template>
+ <FormSwitch v-model="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch>
+ <FormSwitch v-model="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch>
+ <FormSwitch v-model="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch>
+ <FormSwitch v-model="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch>
+ </FormGroup>
+
+ <FormSelect v-model="serverDisconnectedBehavior">
+ <template #label>{{ $ts.whenServerDisconnected }}</template>
+ <option value="reload">{{ $ts._serverDisconnectedBehavior.reload }}</option>
+ <option value="dialog">{{ $ts._serverDisconnectedBehavior.dialog }}</option>
+ <option value="quiet">{{ $ts._serverDisconnectedBehavior.quiet }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.appearance }}</template>
+ <FormSwitch v-model="disableAnimatedMfm">{{ $ts.disableAnimatedMfm }}</FormSwitch>
+ <FormSwitch v-model="reduceAnimation">{{ $ts.reduceUiAnimation }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffect">{{ $ts.useBlurEffect }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffectForModal">{{ $ts.useBlurEffectForModal }}</FormSwitch>
+ <FormSwitch v-model="showGapBetweenNotesInTimeline">{{ $ts.showGapBetweenNotesInTimeline }}</FormSwitch>
+ <FormSwitch v-model="loadRawImages">{{ $ts.loadRawImages }}</FormSwitch>
+ <FormSwitch v-model="disableShowingAnimatedImages">{{ $ts.disableShowingAnimatedImages }}</FormSwitch>
+ <FormSwitch v-model="squareAvatars">{{ $ts.squareAvatars }}</FormSwitch>
+ <FormSwitch v-model="useSystemFont">{{ $ts.useSystemFont }}</FormSwitch>
+ <FormSwitch v-model="useOsNativeEmojis">{{ $ts.useOsNativeEmojis }}
+ <div><Mfm text="๐Ÿฎ๐Ÿฆ๐Ÿญ๐Ÿฉ๐Ÿฐ๐Ÿซ๐Ÿฌ๐Ÿฅž๐Ÿช" :key="useOsNativeEmojis"/></div>
+ </FormSwitch>
+ </FormGroup>
+
+ <FormGroup>
+ <FormSwitch v-model="aiChanMode">{{ $ts.aiChanMode }}</FormSwitch>
+ </FormGroup>
+
+ <FormRadios v-model="fontSize">
+ <template #desc>{{ $ts.fontSize }}</template>
+ <option value="small"><span style="font-size: 14px;">Aa</span></option>
+ <option :value="null"><span style="font-size: 16px;">Aa</span></option>
+ <option value="large"><span style="font-size: 18px;">Aa</span></option>
+ <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option>
+ </FormRadios>
+
+ <FormSelect v-model="instanceTicker">
+ <template #label>{{ $ts.instanceTicker }}</template>
+ <option value="none">{{ $ts._instanceTicker.none }}</option>
+ <option value="remote">{{ $ts._instanceTicker.remote }}</option>
+ <option value="always">{{ $ts._instanceTicker.always }}</option>
+ </FormSelect>
+
+ <FormSelect v-model="nsfw">
+ <template #label>{{ $ts.nsfw }}</template>
+ <option value="respect">{{ $ts._nsfw.respect }}</option>
+ <option value="ignore">{{ $ts._nsfw.ignore }}</option>
+ <option value="force">{{ $ts._nsfw.force }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.defaultNavigationBehaviour }}</template>
+ <FormSwitch v-model="defaultSideView">{{ $ts.openInSideView }}</FormSwitch>
+ </FormGroup>
+
+ <FormSelect v-model="chatOpenBehavior">
+ <template #label>{{ $ts.chatOpenBehavior }}</template>
+ <option value="page">{{ $ts.showInPage }}</option>
+ <option value="window">{{ $ts.openInWindow }}</option>
+ <option value="popout">{{ $ts.popout }}</option>
+ </FormSelect>
+
+ <FormLink to="/settings/deck">{{ $ts.deck }}</FormLink>
+
+ <FormLink to="/settings/custom-css"><template #icon><i class="fas fa-code"></i></template>{{ $ts.customCss }}</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import MkLink from '@/components/link.vue';
+import { langs } from '@/config';
+import { defaultStore } from '@/store';
+import { ColdDeviceStorage } from '@/store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkLink,
+ FormSwitch,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.general,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)'
+ },
+ langs,
+ lang: localStorage.getItem('lang'),
+ fontSize: localStorage.getItem('fontSize'),
+ useSystemFont: localStorage.getItem('useSystemFont') != null,
+ }
+ },
+
+ computed: {
+ serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'),
+ reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v),
+ useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'),
+ useBlurEffect: defaultStore.makeGetterSetter('useBlurEffect'),
+ showGapBetweenNotesInTimeline: defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'),
+ disableAnimatedMfm: defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v),
+ useOsNativeEmojis: defaultStore.makeGetterSetter('useOsNativeEmojis'),
+ disableShowingAnimatedImages: defaultStore.makeGetterSetter('disableShowingAnimatedImages'),
+ loadRawImages: defaultStore.makeGetterSetter('loadRawImages'),
+ imageNewTab: defaultStore.makeGetterSetter('imageNewTab'),
+ nsfw: defaultStore.makeGetterSetter('nsfw'),
+ disablePagesScript: defaultStore.makeGetterSetter('disablePagesScript'),
+ showFixedPostForm: defaultStore.makeGetterSetter('showFixedPostForm'),
+ defaultSideView: defaultStore.makeGetterSetter('defaultSideView'),
+ chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'),
+ instanceTicker: defaultStore.makeGetterSetter('instanceTicker'),
+ enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'),
+ useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'),
+ squareAvatars: defaultStore.makeGetterSetter('squareAvatars'),
+ aiChanMode: defaultStore.makeGetterSetter('aiChanMode'),
+ },
+
+ watch: {
+ lang() {
+ localStorage.setItem('lang', this.lang);
+ localStorage.removeItem('locale');
+ this.reloadAsk();
+ },
+
+ fontSize() {
+ if (this.fontSize == null) {
+ localStorage.removeItem('fontSize');
+ } else {
+ localStorage.setItem('fontSize', this.fontSize);
+ }
+ this.reloadAsk();
+ },
+
+ useSystemFont() {
+ if (this.useSystemFont) {
+ localStorage.setItem('useSystemFont', 't');
+ } else {
+ localStorage.removeItem('useSystemFont');
+ }
+ this.reloadAsk();
+ },
+
+ enableInfiniteScroll() {
+ this.reloadAsk();
+ },
+
+ squareAvatars() {
+ this.reloadAsk();
+ },
+
+ aiChanMode() {
+ this.reloadAsk();
+ },
+
+ showGapBetweenNotesInTimeline() {
+ this.reloadAsk();
+ },
+
+ instanceTicker() {
+ this.reloadAsk();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async reloadAsk() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
new file mode 100644
index 0000000000..8923483b98
--- /dev/null
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -0,0 +1,112 @@
+<template>
+<div style="margin: 16px;">
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.allNotes }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.followingList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.userLists }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.muteList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.blockingList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSection,
+ MkButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.importAndExport,
+ icon: 'fas fa-boxes',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ doExport(target) {
+ os.api(
+ target === 'notes' ? 'i/export-notes' :
+ target === 'following' ? 'i/export-following' :
+ target === 'blocking' ? 'i/export-blocking' :
+ target === 'user-lists' ? 'i/export-user-lists' :
+ target === 'muting' ? 'i/export-mute' :
+ null, {})
+ .then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$ts.exportRequested
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ async doImport(target, e) {
+ const file = await selectFile(e.currentTarget || e.target);
+
+ os.api(
+ target === 'following' ? 'i/import-following' :
+ target === 'user-lists' ? 'i/import-user-lists' :
+ target === 'muting' ? 'i/import-muting' :
+ target === 'blocking' ? 'i/import-blocking' :
+ null, {
+ fileId: file.id
+ }).then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$ts.importRequested
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style module>
+.button {
+ margin-right: 16px;
+}
+</style>
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
new file mode 100644
index 0000000000..b9d3903269
--- /dev/null
+++ b/packages/client/src/pages/settings/index.vue
@@ -0,0 +1,326 @@
+<template>
+<div class="vvcocwet" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || page == null">
+ <MkSpacer :content-max="700">
+ <div class="baaadecd">
+ <div class="title">{{ $ts.settings }}</div>
+ <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div class="main">
+ <component :is="component" :key="page" v-bind="pageProps"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkInfo from '@/components/ui/info.vue';
+import MkSuperMenu from '@/components/ui/super-menu.vue';
+import { scroll } from '@/scripts/scroll';
+import { signout } from '@/account';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+import { instance } from '@/instance';
+import { $i } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkInfo,
+ MkSuperMenu,
+ },
+
+ props: {
+ initialPage: {
+ type: String,
+ required: false
+ }
+ },
+
+ setup(props, context) {
+ const indexInfo = {
+ title: i18n.locale.settings,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ hideHeader: true,
+ };
+ const INFO = ref(indexInfo);
+ const page = ref(props.initialPage);
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const menuDef = computed(() => [{
+ title: i18n.locale.basicSettings,
+ items: [{
+ icon: 'fas fa-user',
+ text: i18n.locale.profile,
+ to: '/settings/profile',
+ active: page.value === 'profile',
+ }, {
+ icon: 'fas fa-lock-open',
+ text: i18n.locale.privacy,
+ to: '/settings/privacy',
+ active: page.value === 'privacy',
+ }, {
+ icon: 'fas fa-laugh',
+ text: i18n.locale.reaction,
+ to: '/settings/reaction',
+ active: page.value === 'reaction',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.drive,
+ to: '/settings/drive',
+ active: page.value === 'drive',
+ }, {
+ icon: 'fas fa-bell',
+ text: i18n.locale.notifications,
+ to: '/settings/notifications',
+ active: page.value === 'notifications',
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.email,
+ to: '/settings/email',
+ active: page.value === 'email',
+ }, {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.integration,
+ to: '/settings/integration',
+ active: page.value === 'integration',
+ }, {
+ icon: 'fas fa-lock',
+ text: i18n.locale.security,
+ to: '/settings/security',
+ active: page.value === 'security',
+ }],
+ }, {
+ title: i18n.locale.clientSettings,
+ items: [{
+ icon: 'fas fa-cogs',
+ text: i18n.locale.general,
+ to: '/settings/general',
+ active: page.value === 'general',
+ }, {
+ icon: 'fas fa-palette',
+ text: i18n.locale.theme,
+ to: '/settings/theme',
+ active: page.value === 'theme',
+ }, {
+ icon: 'fas fa-list-ul',
+ text: i18n.locale.menu,
+ to: '/settings/menu',
+ active: page.value === 'menu',
+ }, {
+ icon: 'fas fa-music',
+ text: i18n.locale.sounds,
+ to: '/settings/sounds',
+ active: page.value === 'sounds',
+ }, {
+ icon: 'fas fa-plug',
+ text: i18n.locale.plugins,
+ to: '/settings/plugin',
+ active: page.value === 'plugin',
+ }],
+ }, {
+ title: i18n.locale.otherSettings,
+ items: [{
+ icon: 'fas fa-boxes',
+ text: i18n.locale.importAndExport,
+ to: '/settings/import-export',
+ active: page.value === 'import-export',
+ }, {
+ icon: 'fas fa-ban',
+ text: i18n.locale.muteAndBlock,
+ to: '/settings/mute-block',
+ active: page.value === 'mute-block',
+ }, {
+ icon: 'fas fa-comment-slash',
+ text: i18n.locale.wordMute,
+ to: '/settings/word-mute',
+ active: page.value === 'word-mute',
+ }, {
+ icon: 'fas fa-key',
+ text: 'API',
+ to: '/settings/api',
+ active: page.value === 'api',
+ }, {
+ icon: 'fas fa-ellipsis-h',
+ text: i18n.locale.other,
+ to: '/settings/other',
+ active: page.value === 'other',
+ }],
+ }, {
+ items: [{
+ type: 'button',
+ icon: 'fas fa-trash',
+ text: i18n.locale.clearCache,
+ action: () => {
+ localStorage.removeItem('locale');
+ localStorage.removeItem('theme');
+ unisonReload();
+ },
+ }, {
+ type: 'button',
+ icon: 'fas fa-sign-in-alt fa-flip-horizontal',
+ text: i18n.locale.logout,
+ action: () => {
+ signout();
+ },
+ danger: true,
+ },],
+ }]);
+
+ const pageProps = ref({});
+ const component = computed(() => {
+ if (page.value == null) return null;
+ switch (page.value) {
+ case 'accounts': return defineAsyncComponent(() => import('./accounts.vue'));
+ case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
+ case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
+ case 'reaction': return defineAsyncComponent(() => import('./reaction.vue'));
+ case 'drive': return defineAsyncComponent(() => import('./drive.vue'));
+ case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
+ case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
+ case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
+ case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
+ case 'security': return defineAsyncComponent(() => import('./security.vue'));
+ case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
+ case 'api': return defineAsyncComponent(() => import('./api.vue'));
+ case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
+ case 'other': return defineAsyncComponent(() => import('./other.vue'));
+ case 'general': return defineAsyncComponent(() => import('./general.vue'));
+ case 'email': return defineAsyncComponent(() => import('./email.vue'));
+ case 'email/address': return defineAsyncComponent(() => import('./email-address.vue'));
+ case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue'));
+ case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
+ case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
+ case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
+ case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
+ case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
+ case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
+ case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
+ case 'plugin': return defineAsyncComponent(() => import('./plugin.vue'));
+ case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue'));
+ case 'plugin/manage': return defineAsyncComponent(() => import('./plugin.manage.vue'));
+ case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
+ case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
+ case 'update': return defineAsyncComponent(() => import('./update.vue'));
+ case 'registry': return defineAsyncComponent(() => import('./registry.vue'));
+ case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue'));
+ case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue'));
+ }
+ if (page.value.startsWith('registry/keys/system/')) {
+ return defineAsyncComponent(() => import('./registry.keys.vue'));
+ }
+ if (page.value.startsWith('registry/value/system/')) {
+ return defineAsyncComponent(() => import('./registry.value.vue'));
+ }
+ });
+
+ watch(component, () => {
+ pageProps.value = {};
+
+ if (page.value) {
+ if (page.value.startsWith('registry/keys/system/')) {
+ pageProps.value.scope = page.value.replace('registry/keys/system/', '').split('/');
+ }
+ if (page.value.startsWith('registry/value/system/')) {
+ const path = page.value.replace('registry/value/system/', '').split('/');
+ pageProps.value.xKey = path.pop();
+ pageProps.value.scope = path;
+ }
+ }
+
+ nextTick(() => {
+ scroll(el.value, { top: 0 });
+ });
+ }, { immediate: true });
+
+ watch(() => props.initialPage, () => {
+ if (props.initialPage == null && !narrow.value) {
+ page.value = 'profile';
+ } else {
+ page.value = props.initialPage;
+ if (props.initialPage == null) {
+ INFO.value = indexInfo;
+ }
+ }
+ });
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 800;
+ if (!narrow.value) {
+ page.value = 'profile';
+ }
+ });
+
+ const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ page,
+ menuDef,
+ narrow,
+ view,
+ el,
+ pageProps,
+ component,
+ emailNotConfigured,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vvcocwet {
+ > .nav {
+ .baaadecd {
+ > .title {
+ margin: 16px;
+ font-size: 1.5em;
+ font-weight: bold;
+ }
+
+ > .info {
+ margin: 0 16px;
+ }
+
+ > .accounts {
+ > .avatar {
+ display: block;
+ width: 50px;
+ height: 50px;
+ margin: 8px auto 16px auto;
+ }
+ }
+ }
+ }
+
+ &.wide {
+ display: flex;
+ max-width: 1000px;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ box-sizing: border-box;
+ overflow: auto;
+
+ .baaadecd {
+ > .title {
+ margin: 24px 0;
+ }
+ }
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
new file mode 100644
index 0000000000..405f93b779
--- /dev/null
+++ b/packages/client/src/pages/settings/integration.vue
@@ -0,0 +1,141 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem" v-if="enableTwitterIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-twitter"></i> Twitter</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.twitter">{{ $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" @click="disconnectTwitter" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectTwitter" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+
+ <div class="_debobigegoItem" v-if="enableDiscordIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-discord"></i> Discord</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.discord">{{ $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" @click="disconnectDiscord" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectDiscord" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+
+ <div class="_debobigegoItem" v-if="enableGithubIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-github"></i> GitHub</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.github">{{ $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" @click="disconnectGithub" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectGithub" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { apiUrl } from '@/config';
+import FormBase from '@/components/debobigego/base.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkButton
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.integration,
+ icon: 'fas fa-share-alt',
+ bg: 'var(--bg)',
+ },
+ apiUrl,
+ twitterForm: null,
+ discordForm: null,
+ githubForm: null,
+ enableTwitterIntegration: false,
+ enableDiscordIntegration: false,
+ enableGithubIntegration: false,
+ };
+ },
+
+ computed: {
+ integrations() {
+ return this.$i.integrations;
+ },
+
+ meta() {
+ return this.$instance;
+ },
+ },
+
+ created() {
+ this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
+ this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
+ this.enableGithubIntegration = this.meta.enableGithubIntegration;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ document.cookie = `igi=${this.$i.token}; path=/;` +
+ ` max-age=31536000;` +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+
+ this.$watch('integrations', () => {
+ if (this.integrations.twitter) {
+ if (this.twitterForm) this.twitterForm.close();
+ }
+ if (this.integrations.discord) {
+ if (this.discordForm) this.discordForm.close();
+ }
+ if (this.integrations.github) {
+ if (this.githubForm) this.githubForm.close();
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ connectTwitter() {
+ this.twitterForm = window.open(apiUrl + '/connect/twitter',
+ 'twitter_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectTwitter() {
+ window.open(apiUrl + '/disconnect/twitter',
+ 'twitter_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectDiscord() {
+ this.discordForm = window.open(apiUrl + '/connect/discord',
+ 'discord_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectDiscord() {
+ window.open(apiUrl + '/disconnect/discord',
+ 'discord_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectGithub() {
+ this.githubForm = window.open(apiUrl + '/connect/github',
+ 'github_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectGithub() {
+ window.open(apiUrl + '/disconnect/github',
+ 'github_disconnect_window',
+ 'height=570, width=520');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue
new file mode 100644
index 0000000000..e40740a3a4
--- /dev/null
+++ b/packages/client/src/pages/settings/menu.vue
@@ -0,0 +1,117 @@
+<template>
+<FormBase>
+ <FormTextarea v-model="items" tall manual-save>
+ <span>{{ $ts.menu }}</span>
+ <template #desc><button class="_textButton" @click="addItem">{{ $ts.addItem }}</button></template>
+ </FormTextarea>
+
+ <FormRadios v-model="menuDisplay">
+ <template #desc>{{ $ts.display }}</template>
+ <option value="sideFull">{{ $ts._menuDisplay.sideFull }}</option>
+ <option value="sideIcon">{{ $ts._menuDisplay.sideIcon }}</option>
+ <option value="top">{{ $ts._menuDisplay.top }}</option>
+ <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ $ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: ใ‚ตใ‚คใƒ‰ใƒใƒผใ‚’ๅฎŒๅ…จใซ้š ใ›ใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹ใจใ€ๅˆฅ้€”ใƒใƒณใƒใƒผใ‚ฌใƒผใƒœใ‚ฟใƒณใฎใ‚ˆใ†ใชใ‚‚ใฎใ‚’UIใซ่กจ็คบใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Š้ขๅ€’ -->
+ </FormRadios>
+
+ <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+import { unisonReload } from '@/scripts/unison-reload';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormRadios,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.menu,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ },
+ menuDef: menuDef,
+ items: defaultStore.state.menu.join('\n'),
+ }
+ },
+
+ computed: {
+ splited(): string[] {
+ return this.items.trim().split('\n').filter(x => x.trim() !== '');
+ },
+
+ menuDisplay: defaultStore.makeGetterSetter('menuDisplay')
+ },
+
+ watch: {
+ menuDisplay() {
+ this.reloadAsk();
+ },
+
+ items() {
+ this.save();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async addItem() {
+ const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.menu.includes(k));
+ const { canceled, result: item } = await os.dialog({
+ type: null,
+ title: this.$ts.addItem,
+ select: {
+ items: [...menu.map(k => ({
+ value: k, text: this.$ts[this.menuDef[k].title]
+ })), ...[{
+ value: '-', text: this.$ts.divider
+ }]]
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.items = [...this.splited, item].join('\n');
+ },
+
+ save() {
+ this.$store.set('menu', this.splited);
+ this.reloadAsk();
+ },
+
+ reset() {
+ this.$store.reset('menu');
+ this.items = this.$store.state.menu.join('\n');
+ },
+
+ async reloadAsk() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..4a9633a20d
--- /dev/null
+++ b/packages/client/src/pages/settings/mute-block.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <MkTab v-model="tab" style="margin-bottom: var(--margin);">
+ <option value="mute">{{ $ts.mutedUsers }}</option>
+ <option value="block">{{ $ts.blockedUsers }}</option>
+ </MkTab>
+ <div v-if="tab === 'mute'">
+ <MkPagination :pagination="mutingPagination" class="muting">
+ <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormGroup>
+ <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
+ <MkAcct :user="mute.mutee"/>
+ </FormLink>
+ </FormGroup>
+ </template>
+ </MkPagination>
+ </div>
+ <div v-if="tab === 'block'">
+ <MkPagination :pagination="blockingPagination" class="blocking">
+ <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormGroup>
+ <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
+ <MkAcct :user="block.blockee"/>
+ </FormLink>
+ </FormGroup>
+ </template>
+ </MkPagination>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkTab,
+ FormInfo,
+ FormBase,
+ FormGroup,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.muteAndBlock,
+ icon: 'fas fa-ban',
+ bg: 'var(--bg)',
+ },
+ tab: 'mute',
+ mutingPagination: {
+ endpoint: 'mute/list',
+ limit: 10,
+ },
+ blockingPagination: {
+ endpoint: 'blocking/list',
+ limit: 10,
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
new file mode 100644
index 0000000000..7de10a182c
--- /dev/null
+++ b/packages/client/src/pages/settings/notifications.vue
@@ -0,0 +1,77 @@
+<template>
+<FormBase>
+ <FormLink @click="configure">{{ $ts.notificationSetting }}</FormLink>
+ <FormGroup>
+ <FormButton @click="readAllNotifications">{{ $ts.markAsReadAllNotifications }}</FormButton>
+ <FormButton @click="readAllUnreadNotes">{{ $ts.markAsReadAllUnreadNotes }}</FormButton>
+ <FormButton @click="readAllMessagingMessages">{{ $ts.markAsReadAllTalkMessages }}</FormButton>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { notificationTypes } from 'misskey-js';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.notifications,
+ icon: 'fas fa-bell',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ readAllUnreadNotes() {
+ os.api('i/read-all-unread-notes');
+ },
+
+ readAllMessagingMessages() {
+ os.api('i/read-all-messaging-messages');
+ },
+
+ readAllNotifications() {
+ os.api('notifications/mark-all-as-read');
+ },
+
+ configure() {
+ const includingTypes = notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
+ os.popup(import('@/components/notification-setting-window.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 => {
+ this.$i.mutingNotificationTypes = i.mutingNotificationTypes;
+ });
+ }
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
new file mode 100644
index 0000000000..fbc895a07d
--- /dev/null
+++ b/packages/client/src/pages/settings/other.vue
@@ -0,0 +1,97 @@
+<template>
+<FormBase>
+ <FormLink to="/settings/update">Misskey Update</FormLink>
+
+ <FormSwitch :value="$i.injectFeaturedNote" @update:modelValue="onChangeInjectFeaturedNote">
+ {{ $ts.showFeaturedNotesInTimeline }}
+ </FormSwitch>
+
+ <FormSwitch v-model="reportError">{{ $ts.sendErrorReports }}<template #desc>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch>
+
+ <FormLink to="/settings/account-info">{{ $ts.accountInfo }}</FormLink>
+ <FormLink to="/settings/experimental-features">{{ $ts.experimentalFeatures }}</FormLink>
+
+ <FormGroup>
+ <template #label>{{ $ts.developer }}</template>
+ <FormSwitch v-model="debug" @update:modelValue="changeDebug">
+ DEBUG MODE
+ </FormSwitch>
+ <template v-if="debug">
+ <FormButton @click="taskmanager">Task Manager</FormButton>
+ </template>
+ </FormGroup>
+
+ <FormLink to="/settings/registry"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.registry }}</FormLink>
+
+ <FormLink to="/bios" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>BIOS</FormLink>
+ <FormLink to="/cli" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>CLI</FormLink>
+
+ <FormLink to="/settings/delete-account"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ $ts.closeAccount }}</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { debug } from '@/config';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.other,
+ icon: 'fas fa-ellipsis-h',
+ bg: 'var(--bg)',
+ },
+ debug,
+ }
+ },
+
+ computed: {
+ reportError: defaultStore.makeGetterSetter('reportError'),
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ changeDebug(v) {
+ console.log(v);
+ localStorage.setItem('debug', v.toString());
+ unisonReload();
+ },
+
+ onChangeInjectFeaturedNote(v) {
+ os.api('i/update', {
+ injectFeaturedNote: v
+ });
+ },
+
+ taskmanager() {
+ os.popup(import('@/components/taskmanager.vue'), {
+ }, {}, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
new file mode 100644
index 0000000000..9958f98f58
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.install.vue
@@ -0,0 +1,147 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts._plugin.installWarn }}</FormInfo>
+
+ <FormGroup>
+ <FormTextarea v-model="code" tall>
+ <span>{{ $ts.code }}</span>
+ </FormTextarea>
+ </FormGroup>
+
+ <FormButton @click="install" :disabled="code == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } 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 FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._plugin.install,
+ icon: 'fas fa-download',
+ bg: 'var(--bg)',
+ },
+ code: null,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ installPlugin({ id, meta, ast, token }) {
+ ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
+ ...meta,
+ id,
+ active: true,
+ configData: {},
+ token: token,
+ ast: ast
+ }));
+ },
+
+ async install() {
+ let ast;
+ try {
+ ast = parse(this.code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ const meta = AiScript.collectMetadata(ast);
+ if (meta == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const data = meta.get(null);
+ if (data == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const { name, version, author, description, permissions, config } = data;
+ if (name == null || version == null || author == null) {
+ os.dialog({
+ type: 'error',
+ text: 'Required property not found :('
+ });
+ return;
+ }
+
+ const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
+ os.popup(import('@/components/token-generate-window.vue'), {
+ title: this.$ts.tokenRequested,
+ information: this.$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');
+ });
+
+ this.installPlugin({
+ id: uuid(),
+ meta: {
+ name, version, author, description, permissions, config
+ },
+ token,
+ ast: serialize(ast)
+ });
+
+ os.success();
+
+ this.$nextTick(() => {
+ unisonReload();
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/plugin.manage.vue b/packages/client/src/pages/settings/plugin.manage.vue
new file mode 100644
index 0000000000..3a0168d13d
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.manage.vue
@@ -0,0 +1,115 @@
+<template>
+<FormBase>
+ <FormGroup v-for="plugin in plugins" :key="plugin.id">
+ <template #label><span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span></template>
+
+ <FormSwitch :value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ $ts.makeActive }}</FormSwitch>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <div class="_keyValue">
+ <div>{{ $ts.author }}:</div>
+ <div>{{ plugin.author }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.description }}:</div>
+ <div>{{ plugin.description }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.permission }}:</div>
+ <div>{{ plugin.permissions }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <MkButton @click="config(plugin)" inline v-if="plugin.config"><i class="fas fa-cog"></i> {{ $ts.settings }}</MkButton>
+ <MkButton @click="uninstall(plugin)" inline danger><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</MkButton>
+ </div>
+ </div>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkTextarea,
+ MkSelect,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._plugin.manage,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ plugins: ColdDeviceStorage.get('plugins'),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ uninstall(plugin) {
+ ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id));
+ os.success();
+ this.$nextTick(() => {
+ unisonReload();
+ });
+ },
+
+ // TODO: ใ“ใฎๅ‡ฆ็†ใ‚’storeๅดใซactionใจใ—ใฆ็งปๅ‹•ใ—ใ€่จญๅฎš็”ป้ขใ‚’้–‹ใAiScriptAPIใ‚’ๅฎŸ่ฃ…ใงใใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹
+ async 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 plugins = ColdDeviceStorage.get('plugins');
+ plugins.find(p => p.id === plugin.id).configData = result;
+ ColdDeviceStorage.set('plugins', plugins);
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ },
+
+ changeActive(plugin, active) {
+ const plugins = ColdDeviceStorage.get('plugins');
+ plugins.find(p => p.id === plugin.id).active = active;
+ ColdDeviceStorage.set('plugins', plugins);
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
new file mode 100644
index 0000000000..50e53f459f
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.vue
@@ -0,0 +1,44 @@
+<template>
+<FormBase>
+ <FormLink to="/settings/plugin/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._plugin.install }}</FormLink>
+ <FormLink to="/settings/plugin/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._plugin.manage }}<template #suffix>{{ plugins }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.plugins,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ plugins: ColdDeviceStorage.get('plugins').length,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
new file mode 100644
index 0000000000..94afba9aa4
--- /dev/null
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -0,0 +1,120 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormSwitch v-model="isLocked" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}</FormSwitch>
+ <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch>
+ <template #caption>{{ $ts.lockedAccountInfo }}</template>
+ </FormGroup>
+ <FormSwitch v-model="publicReactions" @update:modelValue="save()">
+ {{ $ts.makeReactionsPublic }}
+ <template #desc>{{ $ts.makeReactionsPublicDescription }}</template>
+ </FormSwitch>
+ <FormGroup>
+ <template #label>{{ $ts.ffVisibility }}</template>
+ <FormSelect v-model="ffVisibility">
+ <option value="public">{{ $ts._ffVisibility.public }}</option>
+ <option value="followers">{{ $ts._ffVisibility.followers }}</option>
+ <option value="private">{{ $ts._ffVisibility.private }}</option>
+ </FormSelect>
+ <template #caption>{{ $ts.ffVisibilityDescription }}</template>
+ </FormGroup>
+ <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()">
+ {{ $ts.hideOnlineStatus }}
+ <template #desc>{{ $ts.hideOnlineStatusDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="noCrawle" @update:modelValue="save()">
+ {{ $ts.noCrawle }}
+ <template #desc>{{ $ts.noCrawleDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="isExplorable" @update:modelValue="save()">
+ {{ $ts.makeExplorable }}
+ <template #desc>{{ $ts.makeExplorableDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch>
+ <FormGroup v-if="!rememberNoteVisibility">
+ <template #label>{{ $ts.defaultNoteVisibility }}</template>
+ <FormSelect v-model="defaultNoteVisibility">
+ <option value="public">{{ $ts._visibility.public }}</option>
+ <option value="home">{{ $ts._visibility.home }}</option>
+ <option value="followers">{{ $ts._visibility.followers }}</option>
+ <option value="specified">{{ $ts._visibility.specified }}</option>
+ </FormSelect>
+ <FormSwitch v-model="defaultNoteLocalOnly">{{ $ts._visibility.localOnly }}</FormSwitch>
+ </FormGroup>
+ <FormSwitch v-model="keepCw" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormGroup,
+ FormSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.privacy,
+ icon: 'fas fa-lock-open',
+ bg: 'var(--bg)',
+ },
+ isLocked: false,
+ autoAcceptFollowed: false,
+ noCrawle: false,
+ isExplorable: false,
+ hideOnlineStatus: false,
+ publicReactions: false,
+ ffVisibility: 'public',
+ }
+ },
+
+ computed: {
+ defaultNoteVisibility: defaultStore.makeGetterSetter('defaultNoteVisibility'),
+ defaultNoteLocalOnly: defaultStore.makeGetterSetter('defaultNoteLocalOnly'),
+ rememberNoteVisibility: defaultStore.makeGetterSetter('rememberNoteVisibility'),
+ keepCw: defaultStore.makeGetterSetter('keepCw'),
+ },
+
+ created() {
+ this.isLocked = this.$i.isLocked;
+ this.autoAcceptFollowed = this.$i.autoAcceptFollowed;
+ this.noCrawle = this.$i.noCrawle;
+ this.isExplorable = this.$i.isExplorable;
+ this.hideOnlineStatus = this.$i.hideOnlineStatus;
+ this.publicReactions = this.$i.publicReactions;
+ this.ffVisibility = this.$i.ffVisibility;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.api('i/update', {
+ isLocked: !!this.isLocked,
+ autoAcceptFollowed: !!this.autoAcceptFollowed,
+ noCrawle: !!this.noCrawle,
+ isExplorable: !!this.isExplorable,
+ hideOnlineStatus: !!this.hideOnlineStatus,
+ publicReactions: !!this.publicReactions,
+ ffVisibility: this.ffVisibility,
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
new file mode 100644
index 0000000000..a7ddc6d178
--- /dev/null
+++ b/packages/client/src/pages/settings/profile.vue
@@ -0,0 +1,281 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <div class="_debobigegoItem _debobigegoPanel llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
+ <MkAvatar class="avatar" :user="$i"/>
+ </div>
+ <FormButton @click="changeAvatar" primary>{{ $ts._profile.changeAvatar }}</FormButton>
+ <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton>
+ </FormGroup>
+
+ <FormInput v-model="name" :max="30" manual-save>
+ <span>{{ $ts._profile.name }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description" :max="500" tall manual-save>
+ <span>{{ $ts._profile.description }}</span>
+ <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="location" manual-save>
+ <span>{{ $ts.location }}</span>
+ <template #prefix><i class="fas fa-map-marker-alt"></i></template>
+ </FormInput>
+
+ <FormInput v-model="birthday" type="date" manual-save>
+ <span>{{ $ts.birthday }}</span>
+ <template #prefix><i class="fas fa-birthday-cake"></i></template>
+ </FormInput>
+
+ <FormSelect v-model="lang">
+ <template #label>{{ $ts.language }}</template>
+ <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton>
+ <template #caption>{{ $ts._profile.metadataDescription }}</template>
+ </FormGroup>
+
+ <FormSwitch v-model="isCat">{{ $ts.flagAsCat }}<template #desc>{{ $ts.flagAsCatDescription }}</template></FormSwitch>
+
+ <FormSwitch v-model="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch>
+
+ <FormSwitch v-model="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { host, langs } from '@/config';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormSelect,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.profile,
+ icon: 'fas fa-user',
+ bg: 'var(--bg)',
+ },
+ host,
+ langs,
+ name: null,
+ description: null,
+ birthday: null,
+ lang: null,
+ location: null,
+ fieldName0: null,
+ fieldValue0: null,
+ fieldName1: null,
+ fieldValue1: null,
+ fieldName2: null,
+ fieldValue2: null,
+ fieldName3: null,
+ fieldValue3: null,
+ avatarId: null,
+ bannerId: null,
+ isBot: false,
+ isCat: false,
+ alwaysMarkNsfw: false,
+ saving: false,
+ }
+ },
+
+ created() {
+ this.name = this.$i.name;
+ this.description = this.$i.description;
+ this.location = this.$i.location;
+ this.birthday = this.$i.birthday;
+ this.lang = this.$i.lang;
+ this.avatarId = this.$i.avatarId;
+ this.bannerId = this.$i.bannerId;
+ this.isBot = this.$i.isBot;
+ this.isCat = this.$i.isCat;
+ this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw;
+
+ this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null;
+ this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null;
+ this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null;
+ this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null;
+ this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null;
+ this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null;
+ this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null;
+ this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null;
+
+ this.$watch('name', this.save);
+ this.$watch('description', this.save);
+ this.$watch('location', this.save);
+ this.$watch('birthday', this.save);
+ this.$watch('lang', this.save);
+ this.$watch('isBot', this.save);
+ this.$watch('isCat', this.save);
+ this.$watch('alwaysMarkNsfw', this.save);
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ changeAvatar(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => {
+ os.api('i/update', {
+ avatarId: file.id,
+ });
+ });
+ },
+
+ changeBanner(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => {
+ os.api('i/update', {
+ bannerId: file.id,
+ });
+ });
+ },
+
+ async editMetadata() {
+ const { canceled, result } = await os.form(this.$ts._profile.metadata, {
+ fieldName0: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 1',
+ default: this.fieldName0,
+ },
+ fieldValue0: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 1',
+ default: this.fieldValue0,
+ },
+ fieldName1: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 2',
+ default: this.fieldName1,
+ },
+ fieldValue1: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 2',
+ default: this.fieldValue1,
+ },
+ fieldName2: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 3',
+ default: this.fieldName2,
+ },
+ fieldValue2: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 3',
+ default: this.fieldValue2,
+ },
+ fieldName3: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 4',
+ default: this.fieldName3,
+ },
+ fieldValue3: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 4',
+ default: this.fieldValue3,
+ },
+ });
+ if (canceled) return;
+
+ this.fieldName0 = result.fieldName0;
+ this.fieldValue0 = result.fieldValue0;
+ this.fieldName1 = result.fieldName1;
+ this.fieldValue1 = result.fieldValue1;
+ this.fieldName2 = result.fieldName2;
+ this.fieldValue2 = result.fieldValue2;
+ this.fieldName3 = result.fieldName3;
+ this.fieldValue3 = result.fieldValue3;
+
+ const fields = [
+ { name: this.fieldName0, value: this.fieldValue0 },
+ { name: this.fieldName1, value: this.fieldValue1 },
+ { name: this.fieldName2, value: this.fieldValue2 },
+ { name: this.fieldName3, value: this.fieldValue3 },
+ ];
+
+ os.api('i/update', {
+ fields,
+ }).then(i => {
+ os.success();
+ }).catch(err => {
+ os.dialog({
+ type: 'error',
+ text: err.id
+ });
+ });
+ },
+
+ save() {
+ this.saving = true;
+
+ os.apiWithDialog('i/update', {
+ name: this.name || null,
+ description: this.description || null,
+ location: this.location || null,
+ birthday: this.birthday || null,
+ lang: this.lang || null,
+ isBot: !!this.isBot,
+ isCat: !!this.isCat,
+ alwaysMarkNsfw: !!this.alwaysMarkNsfw,
+ }).then(i => {
+ this.saving = false;
+ this.$i.avatarId = i.avatarId;
+ this.$i.avatarUrl = i.avatarUrl;
+ this.$i.bannerId = i.bannerId;
+ this.$i.bannerUrl = i.bannerUrl;
+ }).catch(err => {
+ this.saving = false;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.llvierxe {
+ position: relative;
+ height: 150px;
+ background-size: cover;
+ background-position: center;
+
+ > * {
+ pointer-events: none;
+ }
+
+ > .avatar {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: block;
+ width: 72px;
+ height: 72px;
+ margin: auto;
+ box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
new file mode 100644
index 0000000000..905a3e4957
--- /dev/null
+++ b/packages/client/src/pages/settings/reaction.vue
@@ -0,0 +1,152 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.reactionSettingDescription }}</div>
+ <div class="_debobigegoPanel">
+ <XDraggable class="zoaiodol" v-model="reactions" :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="fas fa-plus"></i></button>
+ </template>
+ </XDraggable>
+ </div>
+ <div class="_debobigegoCaption">{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></div>
+ </div>
+
+ <FormRadios v-model="reactionPickerWidth">
+ <template #desc>{{ $ts.width }}</template>
+ <option :value="1">{{ $ts.small }}</option>
+ <option :value="2">{{ $ts.medium }}</option>
+ <option :value="3">{{ $ts.large }}</option>
+ </FormRadios>
+ <FormRadios v-model="reactionPickerHeight">
+ <template #desc>{{ $ts.height }}</template>
+ <option :value="1">{{ $ts.small }}</option>
+ <option :value="2">{{ $ts.medium }}</option>
+ <option :value="3">{{ $ts.large }}</option>
+ </FormRadios>
+ <FormButton @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ <FormButton danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDraggable from 'vuedraggable';
+import FormInput from '@/components/debobigego/input.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormInput,
+ FormButton,
+ FormBase,
+ FormRadios,
+ XDraggable,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.reaction,
+ icon: 'fas fa-laugh',
+ action: {
+ icon: 'fas fa-eye',
+ handler: this.preview
+ },
+ bg: 'var(--bg)',
+ },
+ reactions: JSON.parse(JSON.stringify(this.$store.state.reactions)),
+ }
+ },
+
+ computed: {
+ reactionPickerWidth: defaultStore.makeGetterSetter('reactionPickerWidth'),
+ reactionPickerHeight: defaultStore.makeGetterSetter('reactionPickerHeight'),
+ },
+
+ watch: {
+ reactions: {
+ handler() {
+ this.save();
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ this.$store.set('reactions', this.reactions);
+ },
+
+ remove(reaction, ev) {
+ os.popupMenu([{
+ text: this.$ts.remove,
+ action: () => {
+ this.reactions = this.reactions.filter(x => x !== reaction)
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ preview(ev) {
+ os.popup(import('@/components/emoji-picker-dialog.vue'), {
+ asReactionPicker: true,
+ src: ev.currentTarget || ev.target,
+ }, {}, 'closed');
+ },
+
+ async setDefault() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.resetAreYouSure,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.reactions = JSON.parse(JSON.stringify(this.$store.def.reactions.default));
+ },
+
+ chooseEmoji(ev) {
+ os.pickEmoji(ev.currentTarget || ev.target, {
+ showPinned: false
+ }).then(emoji => {
+ if (!this.reactions.includes(emoji)) {
+ this.reactions.push(emoji);
+ }
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zoaiodol {
+ padding: 16px;
+
+ > .item {
+ display: inline-block;
+ padding: 8px;
+ cursor: move;
+ }
+
+ > .add {
+ display: inline-block;
+ padding: 8px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/registry.keys.vue b/packages/client/src/pages/settings/registry.keys.vue
new file mode 100644
index 0000000000..ca4d01cc94
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.keys.vue
@@ -0,0 +1,114 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="keys">
+ <template #label>{{ $ts._registry.keys }}</template>
+ <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+ </FormGroup>
+
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ }
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ keys: null,
+ }
+ },
+
+ watch: {
+ scope() {
+ this.fetch();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/keys-with-type', {
+ scope: this.scope
+ }).then(keys => {
+ this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0]));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ default: this.scope.join('/')
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/registry.value.vue b/packages/client/src/pages/settings/registry.value.vue
new file mode 100644
index 0000000000..36f989dbc5
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.value.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo>
+
+ <template v-if="value">
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.key }}</template>
+ <template #value>{{ xKey }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <FormTextarea tall v-model="valueForEditor" class="_monospace" style="tab-size: 2;">
+ <span>{{ $ts.value }} (JSON)</span>
+ </FormTextarea>
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormGroup>
+
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+ </FormKeyValueView>
+
+ <FormButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</FormButton>
+ </template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormInfo,
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormTextarea,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ },
+ xKey: {
+ required: true
+ },
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ value: null,
+ valueForEditor: null,
+ }
+ },
+
+ watch: {
+ key() {
+ this.fetch();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/get-detail', {
+ scope: this.scope,
+ key: this.xKey
+ }).then(value => {
+ this.value = value;
+ this.valueForEditor = JSON5.stringify(this.value.value, null, '\t');
+ });
+ },
+
+ save() {
+ try {
+ JSON5.parse(this.valueForEditor);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.invalidValue
+ });
+ return;
+ }
+
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.saveConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: this.scope,
+ key: this.xKey,
+ value: JSON5.parse(this.valueForEditor)
+ });
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/remove', {
+ scope: this.scope,
+ key: this.xKey
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/registry.vue b/packages/client/src/pages/settings/registry.vue
new file mode 100644
index 0000000000..0bfed0ddb7
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.vue
@@ -0,0 +1,90 @@
+<template>
+<FormBase>
+ <FormGroup v-if="scopes">
+ <template #label>{{ $ts.system }}</template>
+ <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+ </FormGroup>
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ scopes: null,
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/scopes').then(scopes => {
+ this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
new file mode 100644
index 0000000000..4d81bf1b9e
--- /dev/null
+++ b/packages/client/src/pages/settings/security.vue
@@ -0,0 +1,158 @@
+<template>
+<FormBase>
+ <X2fa/>
+ <FormLink to="/settings/2fa"><template #icon><i class="fas fa-mobile-alt"></i></template>{{ $ts.twoStepAuthentication }}</FormLink>
+ <FormButton primary @click="change()">{{ $ts.changePassword }}</FormButton>
+ <FormPagination :pagination="pagination">
+ <template #label>{{ $ts.signinHistory }}</template>
+ <template #default="{items}">
+ <div class="_debobigegoPanel timnmucd" v-for="item in items" :key="item.id">
+ <header>
+ <i v-if="item.success" class="fas fa-check icon succ"></i>
+ <i v-else class="fas fa-times-circle icon fail"></i>
+ <code class="ip _monospace">{{ item.ip }}</code>
+ <MkTime :time="item.createdAt" class="time"/>
+ </header>
+ </div>
+ </template>
+ </FormPagination>
+ <FormGroup>
+ <FormButton danger @click="regenerateToken"><i class="fas fa-sync-alt"></i> {{ $ts.regenerateLoginToken }}</FormButton>
+ <template #caption>{{ $ts.regenerateLoginTokenDescription }}</template>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormPagination from '@/components/debobigego/pagination.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormPagination,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.security,
+ icon: 'fas fa-lock',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/signin-history',
+ limit: 5,
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async change() {
+ const { canceled: canceled1, result: currentPassword } = await os.dialog({
+ title: this.$ts.currentPassword,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await os.dialog({
+ title: this.$ts.newPassword,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await os.dialog({
+ title: this.$ts.newPasswordRetype,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.retypedNotMatch
+ });
+ return;
+ }
+
+ os.apiWithDialog('i/change-password', {
+ currentPassword,
+ newPassword
+ });
+ },
+
+ regenerateToken() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/regenerate_token', {
+ password: password
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.timnmucd {
+ padding: 16px;
+
+ > header {
+ display: flex;
+ align-items: center;
+
+ > .icon {
+ width: 1em;
+ margin-right: 0.75em;
+
+ &.succ {
+ color: var(--success);
+ }
+
+ &.fail {
+ color: var(--error);
+ }
+ }
+
+ > .ip {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 12px;
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
new file mode 100644
index 0000000000..ea3daced9d
--- /dev/null
+++ b/packages/client/src/pages/settings/sounds.vue
@@ -0,0 +1,155 @@
+<template>
+<FormBase>
+ <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05">
+ <template #label><i class="fas fa-volume-icon"></i> {{ $ts.masterVolume }}</template>
+ </FormRange>
+
+ <FormGroup>
+ <template #label>{{ $ts.sounds }}</template>
+ <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)">
+ {{ $t('_sfx.' + type) }}
+ <template #suffix>{{ sounds[type].type || $ts.none }}</template>
+ <template #suffixIcon><i class="fas fa-chevron-down"></i></template>
+ </FormButton>
+ </FormGroup>
+
+ <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormRange from '@/components/debobigego/range.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { playFile } from '@/scripts/sound';
+import * as symbols from '@/symbols';
+
+const soundsTypes = [
+ null,
+ 'syuilo/up',
+ 'syuilo/down',
+ 'syuilo/pope1',
+ 'syuilo/pope2',
+ 'syuilo/waon',
+ 'syuilo/popo',
+ 'syuilo/triple',
+ 'syuilo/poi1',
+ 'syuilo/poi2',
+ 'syuilo/pirori',
+ 'syuilo/pirori-wet',
+ 'syuilo/pirori-square-wet',
+ 'syuilo/square-pico',
+ 'syuilo/reverved',
+ 'syuilo/ryukyu',
+ 'syuilo/kick',
+ 'syuilo/snare',
+ 'syuilo/queue-jammed',
+ 'aisha/1',
+ 'aisha/2',
+ 'aisha/3',
+ 'noizenecio/kick_gaba',
+ 'noizenecio/kick_gaba2',
+];
+
+export default defineComponent({
+ components: {
+ FormSelect,
+ FormButton,
+ FormBase,
+ FormRange,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.sounds,
+ icon: 'fas fa-music',
+ bg: 'var(--bg)',
+ },
+ sounds: {},
+ }
+ },
+
+ computed: {
+ masterVolume: { // TODO: (ๅค–้ƒจ)้–ขๆ•ฐใซcomputedใ‚’ไฝฟใ†ใฎใฏใ‚ขใƒฌใชใฎใง็›ดใ™
+ get() { return ColdDeviceStorage.get('sound_masterVolume'); },
+ set(value) { ColdDeviceStorage.set('sound_masterVolume', value); }
+ },
+ volumeIcon() {
+ return this.masterVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up';
+ }
+ },
+
+ created() {
+ this.sounds.note = ColdDeviceStorage.get('sound_note');
+ this.sounds.noteMy = ColdDeviceStorage.get('sound_noteMy');
+ this.sounds.notification = ColdDeviceStorage.get('sound_notification');
+ this.sounds.chat = ColdDeviceStorage.get('sound_chat');
+ this.sounds.chatBg = ColdDeviceStorage.get('sound_chatBg');
+ this.sounds.antenna = ColdDeviceStorage.get('sound_antenna');
+ this.sounds.channel = ColdDeviceStorage.get('sound_channel');
+ this.sounds.reversiPutBlack = ColdDeviceStorage.get('sound_reversiPutBlack');
+ this.sounds.reversiPutWhite = ColdDeviceStorage.get('sound_reversiPutWhite');
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async edit(type) {
+ const { canceled, result } = await os.form(this.$t('_sfx.' + type), {
+ type: {
+ type: 'enum',
+ enum: soundsTypes.map(x => ({
+ value: x,
+ label: x == null ? this.$ts.none : x,
+ })),
+ label: this.$ts.sound,
+ default: this.sounds[type].type,
+ },
+ volume: {
+ type: 'range',
+ mim: 0,
+ max: 1,
+ step: 0.05,
+ label: this.$ts.volume,
+ default: this.sounds[type].volume
+ },
+ listen: {
+ type: 'button',
+ content: this.$ts.listen,
+ action: (_, values) => {
+ playFile(values.type, values.volume);
+ }
+ }
+ });
+ if (canceled) return;
+
+ const v = {
+ type: result.type,
+ volume: result.volume,
+ };
+
+ ColdDeviceStorage.set('sound_' + type, v);
+ this.sounds[type] = v;
+ },
+
+ reset() {
+ for (const sound of Object.keys(this.sounds)) {
+ const v = ColdDeviceStorage.default['sound_' + sound];
+ ColdDeviceStorage.set('sound_' + sound, v);
+ this.sounds[sound] = v;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
new file mode 100644
index 0000000000..59ad3ad9b7
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.install.vue
@@ -0,0 +1,105 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormTextarea v-model="installThemeCode">
+ <span>{{ $ts._theme.code }}</span>
+ </FormTextarea>
+ <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ </FormGroup>
+
+ <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { applyTheme, validateTheme } from '@/scripts/theme';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme, getThemes } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._theme.install,
+ icon: 'fas fa-download',
+ bg: 'var(--bg)',
+ },
+ installThemeCode: null,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ parseThemeCode(code) {
+ let theme;
+
+ try {
+ theme = JSON5.parse(code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return false;
+ }
+ if (!validateTheme(theme)) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return false;
+ }
+ if (getThemes().some(t => t.id === theme.id)) {
+ os.dialog({
+ type: 'info',
+ text: this.$ts._theme.alreadyInstalled
+ });
+ return false;
+ }
+
+ return theme;
+ },
+
+ preview(code) {
+ const theme = this.parseThemeCode(code);
+ if (theme) applyTheme(theme, false);
+ },
+
+ async install(code) {
+ const theme = this.parseThemeCode(code);
+ if (!theme) return;
+ await addTheme(theme);
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: theme.name })
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
new file mode 100644
index 0000000000..8a24481ae2
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.manage.vue
@@ -0,0 +1,105 @@
+<template>
+<FormBase>
+ <FormSelect v-model="selectedThemeId">
+ <template #label>{{ $ts.theme }}</template>
+ <optgroup :label="$ts._theme.installedThemes">
+ <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts._theme.builtinThemes">
+ <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <template v-if="selectedTheme">
+ <FormInput readonly :modelValue="selectedTheme.author">
+ <span>{{ $ts.author }}</span>
+ </FormInput>
+ <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc">
+ <span>{{ $ts._theme.description }}</span>
+ </FormTextarea>
+ <FormTextarea readonly tall :modelValue="selectedThemeCode">
+ <span>{{ $ts._theme.code }}</span>
+ <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template>
+ </FormTextarea>
+ <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</FormButton>
+ </template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { Theme, builtinThemes } from '@/scripts/theme';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { getThemes, removeTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormInput,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._theme.manage,
+ icon: 'fas fa-folder-open',
+ bg: 'var(--bg)',
+ },
+ installedThemes: getThemes(),
+ builtinThemes,
+ selectedThemeId: null,
+ }
+ },
+
+ computed: {
+ themes(): Theme[] {
+ return this.builtinThemes.concat(this.installedThemes);
+ },
+
+ selectedTheme() {
+ if (this.selectedThemeId == null) return null;
+ return this.themes.find(x => x.id === this.selectedThemeId);
+ },
+
+ selectedThemeCode() {
+ if (this.selectedTheme == null) return null;
+ return JSON5.stringify(this.selectedTheme, null, '\t');
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ copyThemeCode() {
+ copyToClipboard(this.selectedThemeCode);
+ os.success();
+ },
+
+ uninstall() {
+ removeTheme(this.selectedTheme);
+ this.installedThemes = this.installedThemes.filter(t => t.id !== this.selectedThemeId);
+ this.selectedThemeId = null;
+ os.success();
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
new file mode 100644
index 0000000000..a9cca40f3c
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.vue
@@ -0,0 +1,424 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <div class="rfqxtzch _debobigegoItem _debobigegoPanel">
+ <div class="darkMode">
+ <div class="toggleWrapper">
+ <input type="checkbox" class="dn" id="dn" v-model="darkMode"/>
+ <label for="dn" class="toggle">
+ <span class="before">{{ $ts.light }}</span>
+ <span class="after">{{ $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>
+ <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch>
+ </FormGroup>
+
+ <template v-if="darkMode">
+ <FormSelect v-model="darkThemeId">
+ <template #label>{{ $ts.themeForDarkMode }}</template>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <FormSelect v-model="lightThemeId">
+ <template #label>{{ $ts.themeForLightMode }}</template>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ </template>
+ <template v-else>
+ <FormSelect v-model="lightThemeId">
+ <template #label>{{ $ts.themeForLightMode }}</template>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <FormSelect v-model="darkThemeId">
+ <template #label>{{ $ts.themeForDarkMode }}</template>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ </template>
+
+ <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton>
+ <FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton>
+
+ <FormGroup>
+ <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink>
+ <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink>
+ </FormGroup>
+
+ <FormGroup>
+ <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink>
+ <!--<FormLink to="/advanced-theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>-->
+ </FormGroup>
+
+ <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { builtinThemes } from '@/scripts/theme';
+import { selectFile } from '@/scripts/select-file';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { ColdDeviceStorage } from '@/store';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+import { fetchThemes, getThemes } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormSelect,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ setup(props, { emit }) {
+ const INFO = {
+ title: i18n.locale.theme,
+ icon: 'fas fa-palette',
+ bg: 'var(--bg)',
+ };
+
+ const installedThemes = ref(getThemes());
+ const themes = computed(() => builtinThemes.concat(installedThemes.value));
+ const darkThemes = computed(() => themes.value.filter(t => t.base == 'dark' || t.kind == 'dark'));
+ const lightThemes = computed(() => themes.value.filter(t => t.base == 'light' || t.kind == 'light'));
+ const darkTheme = ColdDeviceStorage.ref('darkTheme');
+ const darkThemeId = computed({
+ get() {
+ return darkTheme.value.id;
+ },
+ set(id) {
+ ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id))
+ }
+ });
+ const lightTheme = ColdDeviceStorage.ref('lightTheme');
+ const lightThemeId = computed({
+ get() {
+ return lightTheme.value.id;
+ },
+ set(id) {
+ ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id))
+ }
+ });
+ 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) {
+ defaultStore.set('darkMode', isDeviceDarkmode());
+ }
+ });
+
+ watch(wallpaper, () => {
+ if (wallpaper.value == null) {
+ localStorage.removeItem('wallpaper');
+ } else {
+ localStorage.setItem('wallpaper', wallpaper.value);
+ }
+ location.reload();
+ });
+
+ onMounted(() => {
+ emit('info', INFO);
+ });
+
+ onActivated(() => {
+ fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+ });
+ });
+
+ fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+ });
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ darkThemes,
+ lightThemes,
+ darkThemeId,
+ lightThemeId,
+ darkMode,
+ syncDeviceDarkMode,
+ themesCount,
+ wallpaper,
+ setWallpaper(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ wallpaper.value = file.url;
+ });
+ },
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rfqxtzch {
+ padding: 16px;
+
+ > .darkMode {
+ position: relative;
+ padding: 32px 0;
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ .toggleWrapper {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ overflow: hidden;
+ padding: 0 100px;
+ transform: translate3d(-50%, -50%, 0);
+
+ 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;
+ font-size: 18px;
+ 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;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/update.vue b/packages/client/src/pages/settings/update.vue
new file mode 100644
index 0000000000..aa4050fe9f
--- /dev/null
+++ b/packages/client/src/pages/settings/update.vue
@@ -0,0 +1,95 @@
+<template>
+<FormBase>
+ <template v-if="meta">
+ <FormInfo v-if="version === meta.version">{{ $ts.youAreRunningUpToDateClient }}</FormInfo>
+ <FormInfo v-else warn>{{ $ts.newVersionOfClientAvailable }}</FormInfo>
+ </template>
+ <FormGroup>
+ <template #label>{{ instanceName }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.currentVersion }}</template>
+ <template #value>{{ version }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestVersion }}</template>
+ <template #value v-if="meta">{{ meta.version }}</template>
+ <template #value v-else><MkEllipsis/></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <template #label>Misskey</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestVersion }}</template>
+ <template #value v-if="releases">{{ releases[0].tag_name }}</template>
+ <template #value v-else><MkEllipsis/></template>
+ </FormKeyValueView>
+ <template #caption v-if="releases"><MkTime :time="releases[0].published_at" mode="detail"/></template>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { version, instanceName } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Misskey Update',
+ icon: 'fas fa-sync-alt',
+ bg: 'var(--bg)',
+ },
+ version,
+ instanceName,
+ releases: null,
+ meta: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('meta', {
+ detail: false
+ }).then(meta => {
+ this.meta = meta;
+ localStorage.setItem('v', meta.version);
+ });
+
+ fetch('https://api.github.com/repos/misskey-dev/misskey/releases', {
+ method: 'GET',
+ })
+ .then(res => res.json())
+ .then(res => {
+ this.releases = res;
+ });
+ },
+
+ methods: {
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
new file mode 100644
index 0000000000..c2162bb1f3
--- /dev/null
+++ b/packages/client/src/pages/settings/word-mute.vue
@@ -0,0 +1,110 @@
+<template>
+<div>
+ <MkTab v-model="tab">
+ <option value="soft">{{ $ts._wordMute.soft }}</option>
+ <option value="hard">{{ $ts._wordMute.hard }}</option>
+ </MkTab>
+ <FormBase>
+ <div class="_debobigegoItem">
+ <div v-show="tab === 'soft'">
+ <FormInfo>{{ $ts._wordMute.softDescription }}</FormInfo>
+ <FormTextarea v-model="softMutedWords">
+ <span>{{ $ts._wordMute.muteWords }}</span>
+ <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ </div>
+ <div v-show="tab === 'hard'">
+ <FormInfo>{{ $ts._wordMute.hardDescription }}</FormInfo>
+ <FormTextarea v-model="hardMutedWords">
+ <span>{{ $ts._wordMute.muteWords }}</span>
+ <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ <FormKeyValueView v-if="hardWordMutedNotesCount != null">
+ <template #key>{{ $ts._wordMute.mutedNotes }}</template>
+ <template #value>{{ number(hardWordMutedNotesCount) }}</template>
+ </FormKeyValueView>
+ </div>
+ </div>
+ <FormButton @click="save()" primary inline :disabled="!changed"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormBase>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormKeyValueView,
+ MkTab,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.wordMute,
+ icon: 'fas fa-comment-slash',
+ bg: 'var(--bg)',
+ },
+ tab: 'soft',
+ softMutedWords: '',
+ hardMutedWords: '',
+ hardWordMutedNotesCount: null,
+ changed: false,
+ }
+ },
+
+ watch: {
+ softMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ hardMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ },
+
+ async created() {
+ this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n');
+ this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n');
+
+ this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async save() {
+ this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')));
+ await os.api('i/update', {
+ mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.changed = false;
+ },
+
+ number
+ }
+});
+</script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
new file mode 100644
index 0000000000..c0af44fdd1
--- /dev/null
+++ b/packages/client/src/pages/share.vue
@@ -0,0 +1,184 @@
+<template>
+<div class="">
+ <section class="_section">
+ <div class="_content">
+ <XPostForm
+ v-if="state === 'writing'"
+ fixed
+ :share="true"
+ :initial-text="initialText"
+ :initial-visibility="visibility"
+ :initial-files="files"
+ :initial-local-only="localOnly"
+ :reply="reply"
+ :renote="renote"
+ :visible-users="visibleUsers"
+ @posted="state = 'posted'"
+ class="_panel"
+ />
+ <MkButton v-else-if="state === 'posted'" primary @click="close()" class="close">{{ $ts.close }}</MkButton>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
+
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XPostForm from '@/components/post-form.vue';
+import * as os from '@/os';
+import { noteVisibilities } from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import * as symbols from '@/symbols';
+import * as Misskey from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XPostForm,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.share,
+ icon: 'fas fa-share-alt'
+ },
+ state: 'fetching' as 'fetching' | 'writing' | 'posted',
+
+ title: null as string | null,
+ initialText: null as string | null,
+ reply: null as Misskey.entities.Note | null,
+ renote: null as Misskey.entities.Note | null,
+ visibility: null as string | null,
+ localOnly: null as boolean | null,
+ files: [] as Misskey.entities.DriveFile[],
+ visibleUsers: [] as Misskey.entities.User[],
+ }
+ },
+
+ async created() {
+ const urlParams = new URLSearchParams(window.location.search);
+
+ this.title = urlParams.get('title');
+ const text = urlParams.get('text');
+ const url = urlParams.get('url');
+
+ let noteText = '';
+ if (this.title) noteText += `[ ${this.title} ]\n`;
+ // Googleใƒ‹ใƒฅใƒผใ‚นๅฏพ็ญ–
+ if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, '');
+ else if (text && this.title !== text) noteText += `${text}\n`;
+ if (url) noteText += `${url}`;
+ this.initialText = noteText.trim();
+
+ const visibility = urlParams.get('visibility');
+ if (noteVisibilities.includes(visibility)) {
+ this.visibility = visibility;
+ }
+
+ if (this.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 => {
+ this.visibleUsers.push(user);
+ }, () => {
+ console.error(`Invalid user query: ${JSON.stringify(q)}`);
+ })
+ )
+ );
+ }
+
+ const localOnly = urlParams.get('localOnly');
+ if (localOnly === '0') this.localOnly = false;
+ else if (localOnly === '1') this.localOnly = true;
+
+ try {
+ //#region Reply
+ const replyId = urlParams.get('replyId');
+ const replyUri = urlParams.get('replyUri');
+ if (replyId) {
+ this.reply = await os.api('notes/show', {
+ noteId: replyId
+ });
+ } else if (replyUri) {
+ const obj = await os.api('ap/show', {
+ uri: replyUri
+ });
+ if (obj.type === 'Note') {
+ this.reply = obj.object;
+ }
+ }
+ //#endregion
+
+ //#region Renote
+ const renoteId = urlParams.get('renoteId');
+ const renoteUri = urlParams.get('renoteUri');
+ if (renoteId) {
+ this.renote = await os.api('notes/show', {
+ noteId: renoteId
+ });
+ } else if (renoteUri) {
+ const obj = await os.api('ap/show', {
+ uri: renoteUri
+ });
+ if (obj.type === 'Note') {
+ this.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 => {
+ this.files.push(file);
+ }, () => {
+ console.error(`Failed to fetch a file ${fileId}`);
+ })
+ )
+ );
+ }
+ //#endregion
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ title: e.message,
+ text: e.name
+ });
+ }
+
+ this.state = 'writing';
+ },
+
+ methods: {
+ close() {
+ window.close();
+
+ // ้–‰ใ˜ใชใ‘ใ‚Œใฐ100msๅพŒใ‚ฟใ‚คใƒ ใƒฉใ‚คใƒณใซ
+ setTimeout(() => {
+ this.$router.push('/');
+ }, 100);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.close {
+ margin: 16px auto;
+}
+</style>
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
new file mode 100644
index 0000000000..3bbc9938dd
--- /dev/null
+++ b/packages/client/src/pages/signup-complete.vue
@@ -0,0 +1,50 @@
+<template>
+<div>
+ {{ $ts.processing }}
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+
+ },
+
+ props: {
+ code: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.signup,
+ icon: 'fas fa-user'
+ },
+ }
+ },
+
+ mounted() {
+ os.apiWithDialog('signup-pending', {
+ code: this.code,
+ }).then(res => {
+ login(res.i, '/');
+ });
+ },
+
+ methods: {
+
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
new file mode 100644
index 0000000000..f4709659e3
--- /dev/null
+++ b/packages/client/src/pages/tag.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="_section">
+ <XNotes ref="notes" class="_content" :pagination="pagination" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.tag,
+ icon: 'fas fa-hashtag'
+ },
+ pagination: {
+ endpoint: 'notes/search-by-tag',
+ limit: 10,
+ params: () => ({
+ tag: this.tag,
+ })
+ },
+ };
+ },
+
+ watch: {
+ tag() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/test.vue b/packages/client/src/pages/test.vue
new file mode 100644
index 0000000000..9dd9ae5e0c
--- /dev/null
+++ b/packages/client/src/pages/test.vue
@@ -0,0 +1,259 @@
+<template>
+<div class="_section">
+ <div class="_content">
+ <div class="_card _gap">
+ <div class="_title">Dialog</div>
+ <div class="_content">
+ <MkInput v-model="dialogTitle">
+ <template #label>Title</template>
+ </MkInput>
+ <MkInput v-model="dialogBody">
+ <template #label>Body</template>
+ </MkInput>
+ <MkRadio v-model="dialogType" value="info">Info</MkRadio>
+ <MkRadio v-model="dialogType" value="success">Success</MkRadio>
+ <MkRadio v-model="dialogType" value="warning">Warn</MkRadio>
+ <MkRadio v-model="dialogType" value="error">Error</MkRadio>
+ <MkSwitch v-model="dialogCancel">
+ <span>With cancel button</span>
+ </MkSwitch>
+ <MkSwitch v-model="dialogCancelByBgClick">
+ <span>Can cancel by modal bg click</span>
+ </MkSwitch>
+ <MkSwitch v-model="dialogInput">
+ <span>With input field</span>
+ </MkSwitch>
+ <MkButton @click="showDialog()">Show</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ dialogResult }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Form</div>
+ <div class="_content">
+ <MkInput v-model="formTitle">
+ <template #label>Title</template>
+ </MkInput>
+ <MkTextarea v-model="formForm">
+ <template #label>Form</template>
+ </MkTextarea>
+ <MkButton @click="form()">Show</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ formResult }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">MFM</div>
+ <div class="_content">
+ <MkTextarea v-model="mfm">
+ <template #label>MFM</template>
+ </MkTextarea>
+ </div>
+ <div class="_content">
+ <Mfm :text="mfm"/>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectDriveFile</div>
+ <div class="_content">
+ <MkSwitch v-model="selectDriveFileMultiple">
+ <span>Multiple</span>
+ </MkSwitch>
+ <MkButton @click="selectDriveFile()">selectDriveFile</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ JSON.stringify(selectDriveFileResult) }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectDriveFolder</div>
+ <div class="_content">
+ <MkSwitch v-model="selectDriveFolderMultiple">
+ <span>Multiple</span>
+ </MkSwitch>
+ <MkButton @click="selectDriveFolder()">selectDriveFolder</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ JSON.stringify(selectDriveFolderResult) }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectUser</div>
+ <div class="_content">
+ <MkButton @click="selectUser()">selectUser</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ user }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Notification</div>
+ <div class="_content">
+ <MkInput v-model="notificationIconUrl">
+ <template #label>Icon URL</template>
+ </MkInput>
+ <MkInput v-model="notificationHeader">
+ <template #label>Header</template>
+ </MkInput>
+ <MkTextarea v-model="notificationBody">
+ <template #label>Body</template>
+ </MkTextarea>
+ <MkButton @click="createNotification()">createNotification</MkButton>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Waiting dialog</div>
+ <div class="_content">
+ <MkButton inline @click="openWaitingDialog()">icon only</MkButton>
+ <MkButton inline @click="openWaitingDialog('Doing')">with text</MkButton>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Messaging window</div>
+ <div class="_content">
+ <MkButton @click="messagingWindowOpen()">open</MkButton>
+ </div>
+ </div>
+
+ <MkButton @click="resetTutorial()">Reset tutorial</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ MkTextarea,
+ MkRadio,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'TEST',
+ icon: 'fas fa-exclamation-triangle'
+ },
+ dialogTitle: 'Hello',
+ dialogBody: 'World!',
+ dialogType: 'info',
+ dialogCancel: false,
+ dialogCancelByBgClick: true,
+ dialogInput: false,
+ dialogResult: null,
+ formTitle: 'Test form',
+ formForm: JSON.stringify({
+ foo: {
+ type: 'boolean',
+ default: true,
+ label: 'This is a boolean property'
+ },
+ bar: {
+ type: 'number',
+ default: 300,
+ label: 'This is a number property'
+ },
+ baz: {
+ type: 'string',
+ default: 'Misskey makes you happy.',
+ label: 'This is a string property'
+ },
+ qux: {
+ type: 'string',
+ multiline: true,
+ default: 'Misskey makes\nyou happy.',
+ label: 'Multiline string'
+ },
+ }, null, '\t'),
+ formResult: null,
+ mfm: '',
+ selectDriveFileMultiple: false,
+ selectDriveFolderMultiple: false,
+ selectDriveFileResult: null,
+ selectDriveFolderResult: null,
+ user: null,
+ notificationIconUrl: null,
+ notificationHeader: '',
+ notificationBody: '',
+ }
+ },
+
+ methods: {
+ async showDialog() {
+ this.dialogResult = null;
+ this.dialogResult = await os.dialog({
+ type: this.dialogType,
+ title: this.dialogTitle,
+ text: this.dialogBody,
+ showCancelButton: this.dialogCancel,
+ cancelableByBgClick: this.dialogCancelByBgClick,
+ input: this.dialogInput ? {} : null
+ });
+ },
+
+ async form() {
+ this.formResult = null;
+ this.formResult = await os.form(this.formTitle, JSON.parse(this.formForm));
+ },
+
+ async selectDriveFile() {
+ this.selectDriveFileResult = null;
+ this.selectDriveFileResult = await os.selectDriveFile(this.selectDriveFileMultiple);
+ },
+
+ async selectDriveFolder() {
+ this.selectDriveFolderResult = null;
+ this.selectDriveFolderResult = await os.selectDriveFolder(this.selectDriveFolderMultiple);
+ },
+
+ async selectUser() {
+ this.user = null;
+ this.user = await os.selectUser();
+ },
+
+ async createNotification() {
+ os.api('notifications/create', {
+ header: this.notificationHeader,
+ body: this.notificationBody,
+ icon: this.notificationIconUrl,
+ });
+ },
+
+ messagingWindowOpen() {
+ os.pageWindow('/my/messaging');
+ },
+
+ openWaitingDialog(text?) {
+ const promise = new Promise((resolve, reject) => {
+ setTimeout(resolve, 2000);
+ });
+ os.promiseDialog(promise, null, null, text);
+ },
+
+ resetTutorial() {
+ this.$store.set('tutorial', 0);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
new file mode 100644
index 0000000000..d1a892629b
--- /dev/null
+++ b/packages/client/src/pages/theme-editor.vue
@@ -0,0 +1,306 @@
+<template>
+<FormBase class="cwepdizn">
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.backgroundColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.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" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }">
+ <div class="preview" :style="{ background: color.forPreview }"></div>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.accentColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in accentColors" :key="color" @click="setAccentColor(color)" class="color rounded _button" :class="{ active: theme.props.accent === color }">
+ <div class="preview" :style="{ background: color }"></div>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.textColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in fgColors" :key="color" @click="setFgColor(color)" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }">
+ <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <FormGroup v-if="codeEnabled">
+ <FormTextarea v-model="themeCode" tall>
+ <span>{{ $ts._theme.code }}</span>
+ </FormTextarea>
+ <FormButton @click="applyThemeCode" primary>{{ $ts.apply }}</FormButton>
+ </FormGroup>
+ <FormButton v-else @click="codeEnabled = true"><i class="fas fa-code"></i> {{ $ts.editCode }}</FormButton>
+
+ <FormGroup v-if="descriptionEnabled">
+ <FormTextarea v-model="description">
+ <span>{{ $ts._theme.description }}</span>
+ </FormTextarea>
+ </FormGroup>
+ <FormButton v-else @click="descriptionEnabled = true">{{ $ts.addDescription }}</FormButton>
+
+ <FormGroup>
+ <FormButton @click="showPreview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ <FormButton @click="saveAs" primary><i class="fas fa-save"></i> {{ $ts.saveAs }}</FormButton>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import * as tinycolor from 'tinycolor2';
+import { v4 as uuid} from 'uuid';
+import * as JSON5 from 'json5';
+
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+
+import { Theme, applyTheme, validateTheme, darkTheme, lightTheme } from '@/scripts/theme';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormGroup,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.themeEditor,
+ icon: 'fas fa-palette',
+ },
+ theme: {
+ base: 'light',
+ props: lightTheme.props
+ } as Theme,
+ codeEnabled: false,
+ descriptionEnabled: false,
+ description: null,
+ themeCode: null,
+ 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' },
+ ],
+ accentColors: ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'],
+ 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' },
+ ],
+ changed: false,
+ }
+ },
+
+ created() {
+ this.$watch('theme', this.apply, { deep: true });
+ window.addEventListener('beforeunload', this.beforeunload);
+ },
+
+ beforeUnmount() {
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ async beforeRouteLeave(to, from) {
+ if (this.changed && !(await this.leaveConfirm())) {
+ return false;
+ }
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async leaveConfirm(): Promise<boolean> {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ });
+ return !canceled;
+ },
+
+ showPreview() {
+ os.pageWindow('preview');
+ },
+
+ setBgColor(color) {
+ if (this.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;
+ this.theme.props[prop] = base.props[prop];
+ }
+ }
+ this.theme.base = color.kind;
+ this.theme.props.bg = color.color;
+
+ if (this.theme.props.fg) {
+ const matchedFgColor = this.fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(this.theme.props.fg).toRgbString()));
+ if (matchedFgColor) this.setFgColor(matchedFgColor);
+ }
+ },
+
+ setAccentColor(color) {
+ this.theme.props.accent = color;
+ },
+
+ setFgColor(color) {
+ this.theme.props.fg = this.theme.base === 'light' ? color.forLight : color.forDark;
+ },
+
+ apply() {
+ this.themeCode = JSON5.stringify(this.theme, null, '\t');
+ applyTheme(this.theme, false);
+ this.changed = true;
+ },
+
+ applyThemeCode() {
+ let parsed;
+
+ try {
+ parsed = JSON5.parse(this.themeCode);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return;
+ }
+
+ this.theme = parsed;
+ },
+
+ async saveAs() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.name,
+ input: {
+ allowEmpty: false
+ }
+ });
+ if (canceled) return;
+
+ this.theme.id = uuid();
+ this.theme.name = name;
+ this.theme.author = `@${this.$i.username}@${toUnicode(host)}`;
+ if (this.description) this.theme.desc = this.description;
+ addTheme(this.theme);
+ applyTheme(this.theme);
+ if (this.$store.state.darkMode) {
+ ColdDeviceStorage.set('darkTheme', this.theme);
+ } else {
+ ColdDeviceStorage.set('lightTheme', this.theme);
+ }
+ this.changed = false;
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: this.theme.name })
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cwepdizn {
+ max-width: 800px;
+ margin: 0 auto;
+
+ > .colorPicker {
+ > .colors {
+ padding: 32px;
+ text-align: center;
+
+ > .row {
+ > .color {
+ display: inline-block;
+ position: relative;
+ width: 64px;
+ height: 64px;
+ border-radius: 8px;
+
+ > .preview {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: 42px;
+ height: 42px;
+ border-radius: 4px;
+ box-shadow: 0 2px 4px rgb(0 0 0 / 30%);
+ transition: transform 0.15s ease;
+ }
+
+ &:hover {
+ > .preview {
+ transform: scale(1.1);
+ }
+ }
+
+ &.active {
+ box-shadow: 0 0 0 2px var(--divider) inset;
+ }
+
+ &.rounded {
+ border-radius: 999px;
+
+ > .preview {
+ border-radius: 999px;
+ }
+ }
+
+ &.char {
+ line-height: 42px;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue
new file mode 100644
index 0000000000..4d6dd0af41
--- /dev/null
+++ b/packages/client/src/pages/timeline.tutorial.vue
@@ -0,0 +1,131 @@
+<template>
+<div class="_card tbkwesmv">
+ <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts._tutorial.title }}</div>
+ <div class="_content" v-if="tutorial === 0">
+ <div>{{ $ts._tutorial.step1_1 }}</div>
+ <div>{{ $ts._tutorial.step1_2 }}</div>
+ <div>{{ $ts._tutorial.step1_3 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 1">
+ <div>{{ $ts._tutorial.step2_1 }}</div>
+ <div>{{ $ts._tutorial.step2_2 }}</div>
+ <MkA class="_link" to="/settings/profile">{{ $ts.editProfile }}</MkA>
+ </div>
+ <div class="_content" v-else-if="tutorial === 2">
+ <div>{{ $ts._tutorial.step3_1 }}</div>
+ <div>{{ $ts._tutorial.step3_2 }}</div>
+ <div>{{ $ts._tutorial.step3_3 }}</div>
+ <small>{{ $ts._tutorial.step3_4 }}</small>
+ </div>
+ <div class="_content" v-else-if="tutorial === 3">
+ <div>{{ $ts._tutorial.step4_1 }}</div>
+ <div>{{ $ts._tutorial.step4_2 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 4">
+ <div>{{ $ts._tutorial.step5_1 }}</div>
+ <I18n :src="$ts._tutorial.step5_2" tag="div">
+ <template #featured>
+ <MkA class="_link" to="/featured">{{ $ts.featured }}</MkA>
+ </template>
+ <template #explore>
+ <MkA class="_link" to="/explore">{{ $ts.explore }}</MkA>
+ </template>
+ </I18n>
+ <div>{{ $ts._tutorial.step5_3 }}</div>
+ <small>{{ $ts._tutorial.step5_4 }}</small>
+ </div>
+ <div class="_content" v-else-if="tutorial === 5">
+ <div>{{ $ts._tutorial.step6_1 }}</div>
+ <div>{{ $ts._tutorial.step6_2 }}</div>
+ <div>{{ $ts._tutorial.step6_3 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 6">
+ <div>{{ $ts._tutorial.step7_1 }}</div>
+ <I18n :src="$ts._tutorial.step7_2" tag="div">
+ <template #help>
+ <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ $ts.help }}</a>
+ </template>
+ </I18n>
+ <div>{{ $ts._tutorial.step7_3 }}</div>
+ </div>
+
+ <div class="_footer navigation">
+ <div class="step">
+ <button class="arrow _button" @click="tutorial--" :disabled="tutorial === 0">
+ <i class="fas fa-chevron-left"></i>
+ </button>
+ <span>{{ tutorial + 1 }} / 7</span>
+ <button class="arrow _button" @click="tutorial++" :disabled="tutorial === 6">
+ <i class="fas fa-chevron-right"></i>
+ </button>
+ </div>
+ <MkButton class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+ <MkButton class="ok" @click="tutorial++" primary v-else><i class="fas fa-check"></i> {{ $ts.next }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ }
+ },
+
+ computed: {
+ tutorial: {
+ get() { return this.$store.reactiveState.tutorial.value || 0; },
+ set(value) { this.$store.set('tutorial', value); }
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.tbkwesmv {
+ > ._content {
+ > small {
+ opacity: 0.7;
+ }
+ }
+
+ > .navigation {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+
+ > .step {
+ > .arrow {
+ padding: 4px;
+
+ &:disabled {
+ opacity: 0.5;
+ }
+
+ &:first-child {
+ padding-right: 8px;
+ }
+
+ &:last-child {
+ padding-left: 8px;
+ }
+ }
+
+ > span {
+ margin: 0 4px;
+ }
+ }
+
+ > .ok {
+ margin-left: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
new file mode 100644
index 0000000000..911d6f5c6a
--- /dev/null
+++ b/packages/client/src/pages/timeline.vue
@@ -0,0 +1,225 @@
+<template>
+<div class="cmuxhskf" v-size="{ min: [800] }" v-hotkey.global="keymap">
+ <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+ <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="src"
+ :src="src"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import XPostForm from '@/components/post-form.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ name: 'timeline',
+
+ components: {
+ XTimeline,
+ XTutorial: defineAsyncComponent(() => import('./timeline.tutorial.vue')),
+ XPostForm,
+ },
+
+ data() {
+ return {
+ src: 'home',
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.timeline,
+ icon: this.src === 'local' ? 'fas fa-comments' : this.src === 'social' ? 'fas fa-share-alt' : this.src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-list-ul',
+ text: this.$ts.lists,
+ handler: this.chooseList
+ }, {
+ icon: 'fas fa-satellite',
+ text: this.$ts.antennas,
+ handler: this.chooseAntenna
+ }, {
+ icon: 'fas fa-satellite-dish',
+ text: this.$ts.channel,
+ handler: this.chooseChannel
+ }, {
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }],
+ tabs: [{
+ active: this.src === 'home',
+ title: this.$ts._timelines.home,
+ icon: 'fas fa-home',
+ iconOnly: true,
+ onClick: () => { this.src = 'home'; this.saveSrc(); },
+ }, {
+ active: this.src === 'local',
+ title: this.$ts._timelines.local,
+ icon: 'fas fa-comments',
+ iconOnly: true,
+ onClick: () => { this.src = 'local'; this.saveSrc(); },
+ }, {
+ active: this.src === 'social',
+ title: this.$ts._timelines.social,
+ icon: 'fas fa-share-alt',
+ iconOnly: true,
+ onClick: () => { this.src = 'social'; this.saveSrc(); },
+ }, {
+ active: this.src === 'global',
+ title: this.$ts._timelines.global,
+ icon: 'fas fa-globe',
+ iconOnly: true,
+ onClick: () => { this.src = 'global'; this.saveSrc(); },
+ }],
+ })),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+
+ isLocalTimelineAvailable(): boolean {
+ return !this.$instance.disableLocalTimeline || this.$i.isModerator || this.$i.isAdmin;
+ },
+
+ isGlobalTimelineAvailable(): boolean {
+ return !this.$instance.disableGlobalTimeline || this.$i.isModerator || this.$i.isAdmin;
+ },
+ },
+
+ watch: {
+ src() {
+ this.showNav = false;
+ },
+ },
+
+ created() {
+ this.src = this.$store.state.tl.src;
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ async chooseList(ev) {
+ const lists = await os.api('users/lists/list');
+ const items = lists.map(list => ({
+ type: 'link',
+ text: list.name,
+ to: `/timeline/list/${list.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ async chooseAntenna(ev) {
+ const antennas = await os.api('antennas/list');
+ const items = antennas.map(antenna => ({
+ type: 'link',
+ text: antenna.name,
+ indicate: antenna.hasUnreadNote,
+ to: `/timeline/antenna/${antenna.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ async chooseChannel(ev) {
+ const channels = await os.api('channels/followed');
+ const items = channels.map(channel => ({
+ type: 'link',
+ text: channel.name,
+ indicate: channel.hasUnreadNote,
+ to: `/channels/${channel.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ saveSrc() {
+ this.$store.set('tl', {
+ src: this.src,
+ });
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cmuxhskf {
+ 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;
+ }
+ }
+
+ > .post-form {
+ border-radius: var(--radius);
+ }
+
+ > .tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
+ }
+
+ &.min-width_800px {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user-ap-info.vue b/packages/client/src/pages/user-ap-info.vue
new file mode 100644
index 0000000000..6253faa242
--- /dev/null
+++ b/packages/client/src/pages/user-ap-info.vue
@@ -0,0 +1,124 @@
+<template>
+<FormBase>
+ <FormSuspense :p="apPromiseFactory" v-slot="{ result: ap }">
+ <FormGroup>
+ <template #label>ActivityPub</template>
+ <FormKeyValueView>
+ <template #key>Type</template>
+ <template #value><span class="_monospace">{{ ap.type }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>URI</template>
+ <template #value><span class="_monospace">{{ ap.id }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>URL</template>
+ <template #value><span class="_monospace">{{ ap.url }}</span></template>
+ </FormKeyValueView>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Inbox</template>
+ <template #value><span class="_monospace">{{ ap.inbox }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Shared Inbox</template>
+ <template #value><span class="_monospace">{{ ap.sharedInbox || ap.endpoints.sharedInbox }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Outbox</template>
+ <template #value><span class="_monospace">{{ ap.outbox }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormTextarea readonly tall code pre :value="ap.publicKey.publicKeyPem">
+ <span>Public Key</span>
+ </FormTextarea>
+ <FormKeyValueView>
+ <template #key>Discoverable</template>
+ <template #value>{{ ap.discoverable ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>ManuallyApprovesFollowers</template>
+ <template #value>{{ ap.manuallyApprovesFollowers ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormObjectView tall :value="ap">
+ <span>Raw</span>
+ </FormObjectView>
+ <FormGroup>
+ <FormLink :to="`https://${user.host}/.well-known/webfinger?resource=acct:${user.username}`" external>WebFinger</FormLink>
+ </FormGroup>
+ <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+ <FormKeyValueView v-else>
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ },
+
+ props: {
+ userId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.userInfo,
+ icon: 'fas fa-info-circle'
+ },
+ user: null,
+ apPromiseFactory: null,
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ methods: {
+ number,
+ bytes,
+
+ async fetch() {
+ this.user = await os.api('users/show', {
+ userId: this.userId
+ });
+
+ this.apPromiseFactory = () => os.api('ap/get', {
+ uri: this.user.uri || `${url}/users/${this.user.id}`
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
new file mode 100644
index 0000000000..b77d879a7e
--- /dev/null
+++ b/packages/client/src/pages/user-info.vue
@@ -0,0 +1,245 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <div class="_debobigegoItem aeakzknw">
+ <MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+ </div>
+
+ <FormLink :to="userPage(user)">Profile</FormLink>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Acct</template>
+ <template #value><span class="_monospace">{{ acct(user) }}</span></template>
+ </FormKeyValueView>
+
+ <FormKeyValueView>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ user.id }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="iAmModerator">
+ <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:modelValue="toggleModerator" v-model="moderator">{{ $ts.moderator }}</FormSwitch>
+ <FormSwitch @update:modelValue="toggleSilence" v-model="silenced">{{ $ts.silence }}</FormSwitch>
+ <FormSwitch @update:modelValue="toggleSuspend" v-model="suspended">{{ $ts.suspend }}</FormSwitch>
+ </FormGroup>
+
+ <FormGroup>
+ <FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+ <FormButton v-if="user.host == null && iAmModerator" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+ </FormGroup>
+
+ <FormGroup>
+ <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
+
+ <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+ <FormKeyValueView v-else>
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormObjectView tall :value="user">
+ <span>Raw</span>
+ </FormObjectView>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent } from 'vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+import { userPage, acct } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormSwitch,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ },
+
+ props: {
+ userId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.user ? acct(this.user) : this.$ts.userInfo,
+ icon: 'fas fa-info-circle',
+ actions: this.user ? [this.user.url ? {
+ text: this.user.url,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(this.user.url, '_blank');
+ }
+ } : undefined].filter(x => x !== undefined) : [],
+ })),
+ init: null,
+ user: null,
+ info: null,
+ moderator: false,
+ silenced: false,
+ suspended: false,
+ }
+ },
+
+ computed: {
+ iAmModerator(): boolean {
+ return this.$i && (this.$i.isAdmin || this.$i.isModerator);
+ }
+ },
+
+ watch: {
+ userId: {
+ handler() {
+ this.init = this.createFetcher();
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ number,
+ bytes,
+ userPage,
+ acct,
+
+ createFetcher() {
+ if (this.iAmModerator) {
+ return () => Promise.all([os.api('users/show', {
+ userId: this.userId
+ }), os.api('admin/show-user', {
+ userId: this.userId
+ })]).then(([user, info]) => {
+ this.user = user;
+ this.info = info;
+ this.moderator = this.info.isModerator;
+ this.silenced = this.info.isSilenced;
+ this.suspended = this.info.isSuspended;
+ });
+ } else {
+ return () => os.api('users/show', {
+ userId: this.userId
+ }).then((user) => {
+ this.user = user;
+ });
+ }
+ },
+
+ refreshUser() {
+ this.init = this.createFetcher();
+ },
+
+ async updateRemoteUser() {
+ await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id });
+ this.refreshUser();
+ },
+
+ async resetPassword() {
+ const { password } = await os.api('admin/reset-password', {
+ userId: this.user.id,
+ });
+
+ os.dialog({
+ type: 'success',
+ text: this.$t('newPasswordIs', { password })
+ });
+ },
+
+ async toggleSilence(v) {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
+ });
+ if (confirm.canceled) {
+ this.silenced = !v;
+ } else {
+ await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
+ await this.refreshUser();
+ }
+ },
+
+ async toggleSuspend(v) {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
+ });
+ if (confirm.canceled) {
+ this.suspended = !v;
+ } else {
+ await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
+ await this.refreshUser();
+ }
+ },
+
+ async toggleModerator(v) {
+ await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
+ await this.refreshUser();
+ },
+
+ async deleteAllFiles() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.deleteAllFilesConfirm,
+ });
+ if (confirm.canceled) return;
+ const process = async () => {
+ await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
+ os.success();
+ };
+ await process().catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e.toString()
+ });
+ });
+ await this.refreshUser();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.aeakzknw {
+ > .avatar {
+ display: block;
+ margin: 0 auto;
+ width: 64px;
+ height: 64px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
new file mode 100644
index 0000000000..2fc2476fba
--- /dev/null
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="eqqrhokj" v-hotkey.global="keymap" v-size="{ min: [800] }">
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="listId"
+ src="list"
+ :list="listId"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XTimeline,
+ },
+
+ props: {
+ listId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ list: null,
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => this.list ? {
+ title: this.list.name,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }, {
+ icon: 'fas fa-cog',
+ text: this.$ts.settings,
+ handler: this.settings
+ }],
+ } : null),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+ },
+
+ watch: {
+ listId: {
+ async handler() {
+ this.list = await os.api('users/lists/show', {
+ listId: this.listId
+ });
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ settings() {
+ this.$router.push(`/my/lists/${this.listId}`);
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</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;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue
new file mode 100644
index 0000000000..2ec96d2286
--- /dev/null
+++ b/packages/client/src/pages/user/clips.vue
@@ -0,0 +1,50 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="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>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/clips',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue
new file mode 100644
index 0000000000..fec4431419
--- /dev/null
+++ b/packages/client/src/pages/user/follow-list.vue
@@ -0,0 +1,65 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list">
+ <div class="users _isolated">
+ <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkUserInfo from '@/components/user-info.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkUserInfo,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ type: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ type() {
+ this.$refs.list.reload();
+ },
+
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-following-or-followers {
+ > .users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue
new file mode 100644
index 0000000000..fb99cdff19
--- /dev/null
+++ b/packages/client/src/pages/user/gallery.vue
@@ -0,0 +1,56 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}">
+ <div class="jrnovfpt">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkGalleryPostPreview,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/gallery/posts',
+ limit: 6,
+ params: () => ({
+ userId: this.user.id
+ })
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jrnovfpt {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue
new file mode 100644
index 0000000000..e51d6c6090
--- /dev/null
+++ b/packages/client/src/pages/user/index.activity.vue
@@ -0,0 +1,34 @@
+<template>
+<MkContainer>
+ <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
+
+ <div style="padding: 8px;">
+ <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
+import MkChart from '@/components/chart.vue';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ MkChart,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ limit: {
+ type: Number,
+ required: false,
+ default: 40
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue
new file mode 100644
index 0000000000..4c52dceae6
--- /dev/null
+++ b/packages/client/src/pages/user/index.photos.vue
@@ -0,0 +1,107 @@
+<template>
+<MkContainer :max-height="300" :foldable="true">
+ <template #header><i class="fas fa-image" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template>
+ <div class="ujigsodd">
+ <MkLoading v-if="fetching"/>
+ <div class="stream" v-if="!fetching && images.length > 0">
+ <MkA v-for="image in images"
+ class="img"
+ :to="notePage(image.note)"
+ :key="image.id"
+ >
+ <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/>
+ </MkA>
+ </div>
+ <p class="empty" v-if="!fetching && images.length == 0">{{ $ts.nothing }}</p>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import notePage from '@/filters/note';
+import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ ImgWithBlurhash,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+ data() {
+ return {
+ fetching: true,
+ images: [],
+ };
+ },
+ mounted() {
+ const image = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/apng',
+ 'image/vnd.mozilla.apng',
+ ];
+ os.api('users/notes', {
+ userId: this.user.id,
+ fileType: image,
+ excludeNsfw: this.$store.state.nsfw !== 'ignore',
+ limit: 10,
+ }).then(notes => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ this.images.push({
+ note,
+ file
+ });
+ }
+ }
+ this.fetching = false;
+ });
+ },
+ methods: {
+ thumbnail(image: any): string {
+ return this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(image.thumbnailUrl)
+ : image.thumbnailUrl;
+ },
+ notePage
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ujigsodd {
+ padding: 8px;
+
+ > .stream {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ grid-gap: 6px;
+
+ > .img {
+ height: 128px;
+ border-radius: 6px;
+ overflow: clip;
+ }
+ }
+
+ > .empty {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue
new file mode 100644
index 0000000000..eff38ec3c8
--- /dev/null
+++ b/packages/client/src/pages/user/index.timeline.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="yrzkoczt" v-sticky-container>
+ <MkTab v-model="with_" class="tab">
+ <option :value="null">{{ $ts.notes }}</option>
+ <option value="replies">{{ $ts.notesAndReplies }}</option>
+ <option value="files">{{ $ts.withFiles }}</option>
+ </MkTab>
+ <XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNotes from '@/components/notes.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNotes,
+ MkTab,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ watch: {
+ user() {
+ this.$refs.timeline.reload();
+ },
+
+ with_() {
+ this.$refs.timeline.reload();
+ },
+ },
+
+ data() {
+ return {
+ date: null,
+ with_: null,
+ pagination: {
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.user.id,
+ includeReplies: this.with_ === 'replies',
+ withFiles: this.with_ === 'files',
+ untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+ })
+ }
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yrzkoczt {
+ > .tab {
+ margin: calc(var(--margin) / 2) 0;
+ padding: calc(var(--margin) / 2) 0;
+ background: var(--bg);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
new file mode 100644
index 0000000000..d2531c0d1b
--- /dev/null
+++ b/packages/client/src/pages/user/index.vue
@@ -0,0 +1,829 @@
+<template>
+<div>
+<transition name="fade" mode="out-in">
+ <div class="ftskorzw wide" v-if="user && narrow === false">
+ <MkRemoteCaution v-if="user.host != null" :href="user.url"/>
+
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="banner" :style="style"></div>
+ </div>
+ <div class="contents">
+ <div class="side _forceContainerFull_">
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="name">
+ <MkUserName :user="user" :nowrap="false" class="name"/>
+ <MkAcct :user="user" :detail="true" class="acct"/>
+ </div>
+ <div class="followed" v-if="$i && $i.id != user.id && user.isFollowed"><span>{{ $ts.followsYou }}</span></div>
+ <div class="status">
+ <MkA :to="userPage(user)" :class="{ active: page === 'index' }">
+ <b>{{ number(user.notesCount) }}</b>
+ <span>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $ts.followers }}</span>
+ </MkA>
+ </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">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl class="field" v-if="user.location">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl class="field" v-if="user.birthday">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div class="fields" v-if="user.fields.length > 0">
+ <dl class="field" v-for="(field, i) in user.fields" :key="i">
+ <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>
+ <XActivity :user="user" :key="user.id" class="_gap"/>
+ <XPhotos :user="user" :key="user.id" class="_gap"/>
+ </div>
+ <div class="main">
+ <div class="actions">
+ <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+ <MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
+ <template v-if="page === 'index'">
+ <div v-if="user.pinnedNotes.length > 0" class="_gap">
+ <XNote v-for="note in user.pinnedNotes" class="note _gap" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
+ </div>
+ <div class="_gap">
+ <XUserTimeline :user="user"/>
+ </div>
+ </template>
+ <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_gap"/>
+ <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_gap"/>
+ <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+ <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+ </div>
+ </div>
+ </div>
+ <MkSpacer v-else-if="user && narrow === true" :content-max="800">
+ <div class="ftskorzw narrow" v-size="{ max: [500] }">
+ <!-- TODO -->
+ <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+ <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
+
+ <div class="profile">
+ <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+
+ <div class="_block main" :key="user.id">
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <span class="followed" v-if="$i && $i.id != user.id && user.isFollowed">{{ $ts.followsYou }}</span>
+ <div class="actions" v-if="$i">
+ <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-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">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl class="field" v-if="user.location">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl class="field" v-if="user.birthday">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div class="fields" v-if="user.fields.length > 0">
+ <dl class="field" v-for="(field, i) in user.fields" :key="i">
+ <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 :to="userPage(user)" :class="{ active: page === 'index' }" v-click-anime>
+ <b>{{ number(user.notesCount) }}</b>
+ <span>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }" v-click-anime>
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }" v-click-anime>
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $ts.followers }}</span>
+ </MkA>
+ </div>
+ </div>
+ </div>
+
+ <div class="contents">
+ <template v-if="page === 'index'">
+ <div>
+ <div v-if="user.pinnedNotes.length > 0" class="_gap">
+ <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
+ </div>
+ <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
+ <XPhotos :user="user" :key="user.id"/>
+ <XActivity :user="user" :key="user.id" style="margin-top: var(--margin);"/>
+ </div>
+ <div>
+ <XUserTimeline :user="user"/>
+ </div>
+ </template>
+ <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
+ <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
+ <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
+ <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+ <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+ <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+ </div>
+ </div>
+ </MkSpacer>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+</transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import * as age from 's-age';
+import XUserTimeline from './index.timeline.vue';
+import XNote from '@/components/note.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.vue';
+import Progress from '@/scripts/loading';
+import * as Acct from 'misskey-js/built/acct';
+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 * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserTimeline,
+ XNote,
+ MkFollowButton,
+ MkContainer,
+ MkRemoteCaution,
+ MkFolder,
+ MkTab,
+ MkInfo,
+ XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
+ XReactions: defineAsyncComponent(() => import('./reactions.vue')),
+ XClips: defineAsyncComponent(() => import('./clips.vue')),
+ XPages: defineAsyncComponent(() => import('./pages.vue')),
+ XGallery: defineAsyncComponent(() => import('./gallery.vue')),
+ XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
+ XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
+ },
+
+ props: {
+ acct: {
+ type: String,
+ required: true
+ },
+ page: {
+ type: String,
+ required: false,
+ default: 'index'
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.user ? {
+ icon: 'fas fa-user',
+ title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`,
+ subtitle: `@${getAcct(this.user)}`,
+ userName: this.user,
+ avatar: this.user,
+ path: `/@${this.user.username}`,
+ share: {
+ title: this.user.name,
+ },
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.page === 'index',
+ title: this.$ts.overview,
+ icon: 'fas fa-home',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user)); },
+ }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{
+ active: this.page === 'reactions',
+ title: this.$ts.reaction,
+ icon: 'fas fa-laugh',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); },
+ }] : [], {
+ active: this.page === 'clips',
+ title: this.$ts.clips,
+ icon: 'fas fa-paperclip',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/clips'); },
+ }, {
+ active: this.page === 'pages',
+ title: this.$ts.pages,
+ icon: 'fas fa-file-alt',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/pages'); },
+ }, {
+ active: this.page === 'gallery',
+ title: this.$ts.gallery,
+ icon: 'fas fa-icons',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/gallery'); },
+ }],
+ } : null),
+ user: null,
+ error: null,
+ parallaxAnimationId: null,
+ narrow: null,
+ };
+ },
+
+ computed: {
+ style(): any {
+ if (this.user.bannerUrl == null) return {};
+ return {
+ backgroundImage: `url(${ this.user.bannerUrl })`
+ };
+ },
+
+ age(): number {
+ return age(this.user.birthday);
+ }
+ },
+
+ watch: {
+ acct: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ window.requestAnimationFrame(this.parallaxLoop);
+ this.narrow = true//this.$el.clientWidth < 1000;
+ },
+
+ beforeUnmount() {
+ window.cancelAnimationFrame(this.parallaxAnimationId);
+ },
+
+ methods: {
+ getAcct,
+
+ fetch() {
+ if (this.acct == null) return;
+ this.user = null;
+ Progress.start();
+ os.api('users/show', Acct.parse(this.acct)).then(user => {
+ this.user = user;
+ }).catch(e => {
+ this.error = e;
+ }).finally(() => {
+ Progress.done();
+ });
+ },
+
+ menu(ev) {
+ os.popupMenu(getUserMenu(this.user), ev.currentTarget || ev.target);
+ },
+
+ parallaxLoop() {
+ this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
+ this.parallax();
+ },
+
+ parallax() {
+ const banner = this.$refs.banner as any;
+ if (banner == null) return;
+
+ const top = getScrollPosition(this.$el);
+
+ if (top < 0) return;
+
+ const z = 1.75; // ๅฅฅ่กŒใ(ๅฐใ•ใ„ใปใฉๅฅฅ)
+ const pos = -(top / z);
+ banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+ },
+
+ pinnedNoteUpdated(oldValue, newValue) {
+ const i = this.user.pinnedNotes.findIndex(n => n === oldValue);
+ this.user.pinnedNotes[i] = newValue;
+ },
+
+ number,
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.ftskorzw.wide {
+
+ > .banner-container {
+ position: relative;
+ height: 300px;
+ 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;
+ }
+ }
+
+ > .contents {
+ display: flex;
+ padding: 16px;
+
+ > .side {
+ width: 360px;
+
+ > .avatar {
+ display: block;
+ width: 180px;
+ height: 180px;
+ margin: -130px auto 0 auto;
+ }
+
+ > .name {
+ padding: 16px 0px 20px 0;
+ text-align: center;
+
+ > .name {
+ display: block;
+ font-size: 1.75em;
+ font-weight: bold;
+ }
+ }
+
+ > .followed {
+ text-align: center;
+
+ > span {
+ display: inline-block;
+ font-size: 80%;
+ padding: 8px 12px;
+ margin-bottom: 20px;
+ border: solid 0.5px var(--divider);
+ border-radius: 999px;
+ }
+ }
+
+ > .status {
+ display: flex;
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+
+ > a {
+ flex: 1;
+ text-align: center;
+
+ &.active {
+ color: var(--accent);
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > b {
+ display: block;
+ line-height: 16px;
+ }
+
+ > span {
+ font-size: 75%;
+ }
+ }
+ }
+
+ > .description {
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+ }
+
+ > .fields {
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+
+ > .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;
+ }
+
+ > .value {
+ width: 70%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ > .main {
+ flex: 1;
+ margin-left: var(--margin);
+ min-width: 0;
+
+ > .nav {
+ display: flex;
+ align-items: center;
+ margin-top: var(--margin);
+ //font-size: 120%;
+ font-weight: bold;
+
+ > .link {
+ display: inline-block;
+ padding: 15px 24px 12px 24px;
+ text-align: center;
+ border-bottom: solid 3px transparent;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ }
+
+ &:not(.active):hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+
+ > .actions {
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+
+ > .menu {
+ padding: 12px 16px;
+ }
+ }
+ }
+ }
+ }
+}
+
+.ftskorzw.narrow {
+ box-sizing: border-box;
+ overflow: clip;
+ background: var(--bg);
+
+ > .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 {
+ > .profile > .main {
+ > .banner-container {
+ height: 140px;
+
+ > .fade {
+ display: none;
+ }
+
+ > .title {
+ display: none;
+ }
+ }
+
+ > .title {
+ display: block;
+ }
+
+ > .avatar {
+ top: 90px;
+ left: 0;
+ right: 0;
+ width: 92px;
+ height: 92px;
+ margin: auto;
+ }
+
+ > .description {
+ padding: 16px;
+ text-align: center;
+ }
+
+ > .fields {
+ padding: 16px;
+ }
+
+ > .status {
+ padding: 16px;
+ }
+ }
+
+ > .contents {
+ > .nav {
+ font-size: 80%;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue
new file mode 100644
index 0000000000..0bf925d7d5
--- /dev/null
+++ b/packages/client/src/pages/user/pages.vue
@@ -0,0 +1,49 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagePreview from '@/components/page-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkPagePreview,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/pages',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue
new file mode 100644
index 0000000000..3ca3b2aac8
--- /dev/null
+++ b/packages/client/src/pages/user/reactions.vue
@@ -0,0 +1,81 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <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 :note="item.note" @update:note="updated(note, $event)" :key="item.id"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkNote from '@/components/note.vue';
+import MkReactionIcon from '@/components/reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkNote,
+ MkReactionIcon,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/reactions',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.afdcfbfb {
+ > .header {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ margin-bottom: 8px;
+ border-bottom: solid 2px var(--divider);
+
+ > .avatar {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+ }
+
+ > .reaction {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .createdAt {
+ margin-left: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/v.vue b/packages/client/src/pages/v.vue
new file mode 100644
index 0000000000..3b1bb20861
--- /dev/null
+++ b/packages/client/src/pages/v.vue
@@ -0,0 +1,29 @@
+<template>
+<div>
+ <section class="_section">
+ <div class="_content" style="text-align: center;">
+ <img src="/static-assets/icons/512.png" alt="" style="display: block; width: 100px; margin: 0 auto; border-radius: 16px;"/>
+ <div style="margin-top: 0.75em;">Misskey</div>
+ <div style="opacity: 0.5;">v{{ version }}</div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Misskey',
+ icon: null
+ },
+ version,
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue
new file mode 100644
index 0000000000..2e0c520bc6
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.a.vue
@@ -0,0 +1,320 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <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 _panel">
+ <div class="bg">
+ <div class="fade"></div>
+ </div>
+ <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">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton @click="signup()" inline gradate data-cy-signup style="margin-right: 12px;">{{ $ts.signup }}</MkButton>
+ <MkButton @click="signin()" inline data-cy-signin>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <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="fas fa-ellipsis-h"></i></button>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.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: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-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;
+ 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: 160px;
+
+ @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;
+ box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+ @media (max-width: 1200px) {
+ margin: auto;
+ }
+
+ > .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;
+ font-size: 1.5em;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 120px;
+ max-width: min(100%, 300px);
+ }
+ }
+
+ > .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;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue
new file mode 100644
index 0000000000..efb8b09360
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.b.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <div class="top">
+ <MkFeaturedPhotos class="bg"/>
+ <XTimeline class="tl"/>
+ <div class="shape"></div>
+ <div class="main">
+ <h1>
+ <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+ </h1>
+ <div class="about">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton class="signup" @click="signup()" inline gradate>{{ $ts.signup }}</MkButton>
+ <MkButton class="signin" @click="signin()" inline>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <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/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.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: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open(`https://misskey-hub.net/help.md`, '_blank');
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .top {
+ min-height: 100vh;
+ box-sizing: border-box;
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .tl {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 64px;
+ margin: auto;
+ width: 500px;
+ height: calc(100% - 128px);
+ overflow: hidden;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+ }
+
+ > .shape {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--accent);
+ clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%);
+ }
+
+ > .misskey {
+ position: absolute;
+ bottom: 64px;
+ left: 64px;
+ width: 160px;
+ }
+
+ > .main {
+ position: relative;
+ width: min(450px, 100%);
+ padding: 64px;
+ color: #fff;
+ font-size: 1.1em;
+
+ @media (max-width: 1200px) {
+ margin: auto;
+ }
+
+ > h1 {
+ display: block;
+ margin: 0 0 32px 0;
+ padding: 0;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 100px;
+ }
+ }
+
+ > .about {
+ padding: 0;
+ }
+
+ > .action {
+ margin: 32px 0;
+
+ > * {
+ line-height: 32px;
+ }
+
+ > .signup {
+ background: var(--panel);
+ color: var(--fg);
+ }
+
+ > .signin {
+ background: var(--accent);
+ color: inherit;
+ }
+ }
+
+ > .status {
+ margin: 32px 0;
+ border-top: solid 1px rgba(255, 255, 255, 0.5);
+ font-size: 90%;
+
+ > div {
+ padding: 16px 0;
+
+ > span:not(:last-child) {
+ padding-right: 1em;
+ margin-right: 1em;
+ border-right: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue
new file mode 100644
index 0000000000..2b0ff7a31c
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.c.vue
@@ -0,0 +1,305 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <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 class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+ </h1>
+ <div class="about">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton @click="signup()" inline gradate>{{ $ts.signup }}</MkButton>
+ <MkButton @click="signin()" inline>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <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="fas fa-ellipsis-h"></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/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.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: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open(`https://misskey-hub.net/help.md`, '_blank');
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .top {
+ display: flex;
+ text-align: center;
+ min-height: 100vh;
+ box-sizing: border-box;
+ padding: 16px;
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .fade {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.25);
+ }
+
+ > .emojis {
+ position: absolute;
+ bottom: 32px;
+ left: 35px;
+
+ > * {
+ margin-right: 8px;
+ }
+
+ @media (max-width: 1200px) {
+ display: none;
+ }
+ }
+
+ > .main {
+ position: relative;
+ width: min(460px, 100%);
+ margin: auto;
+
+ > .misskey {
+ width: 150px;
+ margin-bottom: 16px;
+
+ @media (max-width: 450px) {
+ width: 130px;
+ }
+ }
+
+ > .form {
+ position: relative;
+ box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background-position: center;
+ background-size: cover;
+ opacity: 0.75;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+ }
+
+ > .fg {
+ position: relative;
+ z-index: 1;
+
+ > h1 {
+ display: block;
+ margin: 0;
+ padding: 32px 32px 24px 32px;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 120px;
+ }
+ }
+
+ > .about {
+ padding: 0 32px;
+ }
+
+ > .action {
+ padding: 32px;
+
+ > * {
+ line-height: 28px;
+ }
+ }
+
+ > .status {
+ border-top: solid 0.5px var(--divider);
+ padding: 32px;
+ font-size: 90%;
+
+ > div {
+ > span:not(:last-child) {
+ padding-right: 1em;
+ margin-right: 1em;
+ border-right: solid 0.5px var(--divider);
+ }
+ }
+
+ > .online {
+ ::v-deep(b) {
+ color: #41b781;
+ }
+
+ ::v-deep(span) {
+ opacity: 0.7;
+ }
+ }
+ }
+
+ > .menu {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .nav {
+ position: relative;
+ z-index: 2;
+ margin-top: 20px;
+ color: #fff;
+ text-shadow: 0 0 8px black;
+ font-size: 0.9em;
+
+ > *:not(:last-child) {
+ margin-right: 1.5em;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue
new file mode 100644
index 0000000000..8c88720cf3
--- /dev/null
+++ b/packages/client/src/pages/welcome.setup.vue
@@ -0,0 +1,102 @@
+<template>
+<form class="mk-setup" @submit.prevent="submit()">
+ <h1>Welcome to Misskey!</h1>
+ <div>
+ <p>{{ $ts.intro }}</p>
+ <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username>
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ </MkInput>
+ <MkInput v-model="password" type="password" data-cy-admin-password>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ </MkInput>
+ <footer>
+ <MkButton primary type="submit" :disabled="submitting" data-cy-admin-ok>
+ {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/>
+ </MkButton>
+ </footer>
+ </div>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { host } from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ },
+
+ data() {
+ return {
+ username: '',
+ password: '',
+ submitting: false,
+ host,
+ }
+ },
+
+ methods: {
+ submit() {
+ if (this.submitting) return;
+ this.submitting = true;
+
+ os.api('admin/accounts/create', {
+ username: this.username,
+ password: this.password,
+ }).then(res => {
+ return login(res.token);
+ }).catch(() => {
+ this.submitting = false;
+
+ os.dialog({
+ type: 'error',
+ text: this.$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;
+ }
+
+ > footer {
+ > * {
+ margin: 0 auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue
new file mode 100644
index 0000000000..46e3dbb5ed
--- /dev/null
+++ b/packages/client/src/pages/welcome.timeline.vue
@@ -0,0 +1,99 @@
+<template>
+<div class="civpbkhh">
+ <div class="scrollbox" ref="scroll" v-bind:class="{ scroll: isScrolling }">
+ <div v-for="note in notes" class="note">
+ <div class="content _panel">
+ <div class="body">
+ <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+ <MkA class="rp" v-if="note.renoteId" :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 :note="note" ref="reactionsViewer"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XReactionsViewer from '@/components/reactions-viewer.vue';
+import XMediaList from '@/components/media-list.vue';
+import XPoll from '@/components/poll.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionsViewer,
+ XMediaList,
+ XPoll
+ },
+
+ data() {
+ return {
+ notes: [],
+ isScrolling: false,
+ }
+ },
+
+ created() {
+ os.api('notes/featured').then(notes => {
+ this.notes = notes;
+ });
+ },
+
+ updated() {
+ if (this.$refs.scroll.clientHeight > window.innerHeight) {
+ this.isScrolling = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes scroll {
+ 0% {
+ transform: translate3d(0, 0, 0);
+ }
+ 5% {
+ transform: translate3d(0, 0, 0);
+ }
+ 75% {
+ transform: translate3d(0, calc(-100% + 90vh), 0);
+ }
+ 90% {
+ transform: translate3d(0, calc(-100% + 90vh), 0);
+ }
+}
+
+.civpbkhh {
+ text-align: right;
+
+ > .scrollbox {
+ &.scroll {
+ animation: scroll 45s linear infinite;
+ }
+
+ > .note {
+ margin: 16px 0 16px auto;
+
+ > .content {
+ padding: 16px;
+ margin: 0 0 0 auto;
+ max-width: max-content;
+ border-radius: 16px;
+
+ > .richcontent {
+ min-width: 250px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
new file mode 100644
index 0000000000..4c038b5113
--- /dev/null
+++ b/packages/client/src/pages/welcome.vue
@@ -0,0 +1,38 @@
+<template>
+<div v-if="meta">
+ <XSetup v-if="meta.requireSetup"/>
+ <XEntrance v-else/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } 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 * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XSetup,
+ XEntrance,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: instanceName,
+ icon: null
+ },
+ meta: null
+ }
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ }
+});
+</script>