summaryrefslogtreecommitdiff
path: root/packages/client/src/pages/settings
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/settings
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/settings')
-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
37 files changed, 4975 insertions, 0 deletions
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>