summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/settings
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages/settings
parentwip: retention for dashboard (diff)
downloadmisskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages/settings')
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue216
-rw-r--r--packages/frontend/src/pages/settings/account-info.vue158
-rw-r--r--packages/frontend/src/pages/settings/accounts.vue143
-rw-r--r--packages/frontend/src/pages/settings/api.vue46
-rw-r--r--packages/frontend/src/pages/settings/apps.vue96
-rw-r--r--packages/frontend/src/pages/settings/custom-css.vue46
-rw-r--r--packages/frontend/src/pages/settings/deck.vue39
-rw-r--r--packages/frontend/src/pages/settings/delete-account.vue52
-rw-r--r--packages/frontend/src/pages/settings/drive.vue145
-rw-r--r--packages/frontend/src/pages/settings/email.vue111
-rw-r--r--packages/frontend/src/pages/settings/general.vue196
-rw-r--r--packages/frontend/src/pages/settings/import-export.vue165
-rw-r--r--packages/frontend/src/pages/settings/index.vue291
-rw-r--r--packages/frontend/src/pages/settings/instance-mute.vue53
-rw-r--r--packages/frontend/src/pages/settings/integration.vue99
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue61
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue87
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue90
-rw-r--r--packages/frontend/src/pages/settings/other.vue47
-rw-r--r--packages/frontend/src/pages/settings/plugin.install.vue124
-rw-r--r--packages/frontend/src/pages/settings/plugin.vue98
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue444
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue100
-rw-r--r--packages/frontend/src/pages/settings/profile.vue220
-rw-r--r--packages/frontend/src/pages/settings/reaction.vue154
-rw-r--r--packages/frontend/src/pages/settings/security.vue160
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue45
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue82
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue140
-rw-r--r--packages/frontend/src/pages/settings/statusbar.vue54
-rw-r--r--packages/frontend/src/pages/settings/theme.install.vue80
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue78
-rw-r--r--packages/frontend/src/pages/settings/theme.vue409
-rw-r--r--packages/frontend/src/pages/settings/webhook.edit.vue95
-rw-r--r--packages/frontend/src/pages/settings/webhook.new.vue82
-rw-r--r--packages/frontend/src/pages/settings/webhook.vue53
-rw-r--r--packages/frontend/src/pages/settings/word-mute.vue128
37 files changed, 4687 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
new file mode 100644
index 0000000000..1803129aaa
--- /dev/null
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -0,0 +1,216 @@
+<template>
+<div>
+ <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton>
+ <template v-if="$i.twoFactorEnabled">
+ <p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
+ <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ i18n.ts.securityKey }}</h2>
+ <p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
+ <div class="key-list">
+ <div v-for="key in $i.securityKeysList" class="key">
+ <h3>{{ key.name }}</h3>
+ <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
+ <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton>
+ </div>
+ </div>
+
+ <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch>
+
+ <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo>
+ <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ i18n.ts.tapSecurityKey }}
+ <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
+ </li>
+ <li v-if="registration.stage >= 1">
+ <MkForm :disabled="registration.stage != 1 || registration.saving">
+ <MkInput v-model="keyName" :max="30">
+ <template #label>{{ i18n.ts.securityKeyName }}</template>
+ </MkInput>
+ <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton>
+ <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/>
+ </MkForm>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="twoFactorData && !$i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <I18n :src="i18n.ts._2fa.step1" tag="span">
+ <template #a>
+ <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
+ </template>
+ <template #b>
+ <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
+ </template>
+ </I18n>
+ </li>
+ <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li>
+ <li>
+ {{ i18n.ts._2fa.step3 }}<br>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
+ <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton>
+ </li>
+ </ol>
+ <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { hostname } from '@/config';
+import { byteify, hexify, stringify } from '@/scripts/2fa';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const twoFactorData = ref<any>(null);
+const supportsCredentials = ref(!!navigator.credentials);
+const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
+const registration = ref<any>(null);
+const keyName = ref('');
+const token = ref(null);
+
+function register() {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register', {
+ password: password,
+ }).then(data => {
+ twoFactorData.value = data;
+ });
+ });
+}
+
+function unregister() {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/unregister', {
+ password: password,
+ }).then(() => {
+ usePasswordLessLogin.value = false;
+ updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ $i!.twoFactorEnabled = false;
+ });
+ });
+}
+
+function submit() {
+ os.api('i/2fa/done', {
+ token: token.value,
+ }).then(() => {
+ os.success();
+ $i!.twoFactorEnabled = true;
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ });
+}
+
+function registerKey() {
+ registration.value.saving = true;
+ os.api('i/2fa/key-done', {
+ password: registration.value.password,
+ name: keyName.value,
+ challengeId: registration.value.challengeId,
+ // we convert each 16 bits to a string to serialise
+ clientDataJSON: stringify(registration.value.credential.response.clientDataJSON),
+ attestationObject: hexify(registration.value.credential.response.attestationObject),
+ }).then(key => {
+ registration.value = null;
+ key.lastUsed = new Date();
+ os.success();
+ });
+}
+
+function unregisterKey(key) {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return os.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id,
+ }).then(() => {
+ usePasswordLessLogin.value = false;
+ updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ });
+ });
+}
+
+function addSecurityKey() {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register-key', {
+ password,
+ }).then(reg => {
+ registration.value = {
+ password,
+ challengeId: reg!.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: byteify(reg!.challenge, 'base64'),
+ rp: {
+ id: hostname,
+ name: 'Misskey',
+ },
+ user: {
+ id: byteify($i!.id, 'ascii'),
+ name: $i!.username,
+ displayName: $i!.name,
+ },
+ pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
+ timeout: 60000,
+ attestation: 'direct',
+ },
+ saving: true,
+ };
+ return navigator.credentials.create({
+ publicKey: registration.value.publicKeyOptions,
+ });
+ }).then(credential => {
+ registration.value.credential = credential;
+ registration.value.saving = false;
+ registration.value.stage = 1;
+ }).catch(err => {
+ console.warn('Error while registering?', err);
+ registration.value.error = err.message;
+ registration.value.stage = -1;
+ });
+ });
+}
+
+async function updatePasswordLessLogin() {
+ await os.api('i/2fa/password-less', {
+ value: !!usePasswordLessLogin.value,
+ });
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue
new file mode 100644
index 0000000000..ccd99c162a
--- /dev/null
+++ b/packages/frontend/src/pages/settings/account-info.vue
@@ -0,0 +1,158 @@
+<template>
+<div class="_formRoot">
+ <MkKeyValue>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ $i.id }}</span></template>
+ </MkKeyValue>
+
+ <FormSection>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.registeredDate }}</template>
+ <template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
+ </MkKeyValue>
+ </FormSection>
+
+ <FormSection v-if="stats">
+ <template #label>{{ i18n.ts.statistics }}</template>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.notesCount }}</template>
+ <template #value>{{ number(stats.notesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.repliesCount }}</template>
+ <template #value>{{ number(stats.repliesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.renotesCount }}</template>
+ <template #value>{{ number(stats.renotesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.repliedCount }}</template>
+ <template #value>{{ number(stats.repliedCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.renotedCount }}</template>
+ <template #value>{{ number(stats.renotedCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.pollVotesCount }}</template>
+ <template #value>{{ number(stats.pollVotesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.pollVotedCount }}</template>
+ <template #value>{{ number(stats.pollVotedCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.sentReactionsCount }}</template>
+ <template #value>{{ number(stats.sentReactionsCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.receivedReactionsCount }}</template>
+ <template #value>{{ number(stats.receivedReactionsCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.noteFavoritesCount }}</template>
+ <template #value>{{ number(stats.noteFavoritesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followingCount }}</template>
+ <template #value>{{ number(stats.followingCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template>
+ <template #value>{{ number(stats.localFollowingCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowingCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followersCount }}</template>
+ <template #value>{{ number(stats.followersCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template>
+ <template #value>{{ number(stats.localFollowersCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowersCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.pageLikesCount }}</template>
+ <template #value>{{ number(stats.pageLikesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.pageLikedCount }}</template>
+ <template #value>{{ number(stats.pageLikedCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.driveFilesCount }}</template>
+ <template #value>{{ number(stats.driveFilesCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.driveUsage }}</template>
+ <template #value>{{ bytes(stats.driveUsage) }}</template>
+ </MkKeyValue>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.other }}</template>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>emailVerified</template>
+ <template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>twoFactorEnabled</template>
+ <template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>securityKeys</template>
+ <template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>usePasswordLessLogin</template>
+ <template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>isModerator</template>
+ <template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>isAdmin</template>
+ <template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template>
+ </MkKeyValue>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import FormSection from '@/components/form/section.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const stats = ref<any>({});
+
+onMounted(() => {
+ os.api('users/stats', {
+ userId: $i!.id,
+ }).then(response => {
+ stats.value = response;
+ });
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.accountInfo,
+ icon: 'ti ti-info-circle',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
new file mode 100644
index 0000000000..493d3b2618
--- /dev/null
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -0,0 +1,143 @@
+<template>
+<div class="_formRoot">
+ <FormSuspense :p="init">
+ <FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton>
+
+ <div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)">
+ <div class="avatar">
+ <MkAvatar :user="account" class="avatar"/>
+ </div>
+ <div class="body">
+ <div class="name">
+ <MkUserName :user="account"/>
+ </div>
+ <div class="acct">
+ <MkAcct :user="account"/>
+ </div>
+ </div>
+ </div>
+ </FormSuspense>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const storedAccounts = ref<any>(null);
+const accounts = ref<any>(null);
+
+const init = async () => {
+ getAccounts().then(accounts => {
+ storedAccounts.value = accounts.filter(x => x.id !== $i!.id);
+
+ console.log(storedAccounts.value);
+
+ return os.api('users/show', {
+ userIds: storedAccounts.value.map(x => x.id),
+ });
+ }).then(response => {
+ accounts.value = response;
+ console.log(accounts.value);
+ });
+};
+
+function menu(account, ev) {
+ os.popupMenu([{
+ text: i18n.ts.switch,
+ icon: 'ti ti-switch-horizontal',
+ action: () => switchAccount(account),
+ }, {
+ text: i18n.ts.remove,
+ icon: 'ti ti-trash',
+ danger: true,
+ action: () => removeAccount(account),
+ }], ev.currentTarget ?? ev.target);
+}
+
+function addAccount(ev) {
+ os.popupMenu([{
+ text: i18n.ts.existingAccount,
+ action: () => { addExistingAccount(); },
+ }, {
+ text: i18n.ts.createAccount,
+ action: () => { createAccount(); },
+ }], ev.currentTarget ?? ev.target);
+}
+
+function removeAccount(account) {
+ _removeAccount(account.id);
+}
+
+function addExistingAccount() {
+ os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+ done: res => {
+ addAccounts(res.id, res.i);
+ os.success();
+ },
+ }, 'closed');
+}
+
+function createAccount() {
+ os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
+ done: res => {
+ addAccounts(res.id, res.i);
+ switchAccountWithToken(res.i);
+ },
+ }, 'closed');
+}
+
+async function switchAccount(account: any) {
+ const fetchedAccounts: any[] = await getAccounts();
+ const token = fetchedAccounts.find(x => x.id === account.id).token;
+ switchAccountWithToken(token);
+}
+
+function switchAccountWithToken(token: string) {
+ login(token);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.accounts,
+ icon: 'ti ti-users',
+});
+</script>
+
+<style lang="scss" scoped>
+.lcjjdxlm {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+
+ > .body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue
new file mode 100644
index 0000000000..8d7291cd10
--- /dev/null
+++ b/packages/frontend/src/pages/settings/api.vue
@@ -0,0 +1,46 @@
+<template>
+<div class="_formRoot">
+ <FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton>
+ <FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink>
+ <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const isDesktop = ref(window.innerWidth >= 1100);
+
+function generateToken() {
+ os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ os.alert({
+ type: 'success',
+ title: i18n.ts.token,
+ text: token,
+ });
+ },
+ }, 'closed');
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'API',
+ icon: 'ti ti-api',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue
new file mode 100644
index 0000000000..05abadff23
--- /dev/null
+++ b/packages/frontend/src/pages/settings/apps.vue
@@ -0,0 +1,96 @@
+<template>
+<div class="_formRoot">
+ <FormPagination ref="list" :pagination="pagination">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ i18n.ts.nothing }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div v-for="token in items" :key="token.id" class="_panel bfomjevm">
+ <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
+ <div class="body">
+ <div class="name">{{ token.name }}</div>
+ <div class="description">{{ token.description }}</div>
+ <div class="_keyValue">
+ <div>{{ i18n.ts.installedDate }}:</div>
+ <div><MkTime :time="token.createdAt"/></div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ i18n.ts.lastUsedDate }}:</div>
+ <div><MkTime :time="token.lastUsedAt"/></div>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button>
+ </div>
+ <details>
+ <summary>{{ i18n.ts.details }}</summary>
+ <ul>
+ <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </details>
+ </div>
+ </div>
+ </template>
+ </FormPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import FormPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const list = ref<any>(null);
+
+const pagination = {
+ endpoint: 'i/apps' as const,
+ limit: 100,
+ params: {
+ sort: '+lastUsedAt',
+ },
+};
+
+function revoke(token) {
+ os.api('i/revoke-token', { tokenId: token.id }).then(() => {
+ list.value.reload();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.installedApps,
+ icon: 'ti ti-plug',
+});
+</script>
+
+<style lang="scss" scoped>
+.bfomjevm {
+ display: flex;
+ padding: 16px;
+
+ > .icon {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue
new file mode 100644
index 0000000000..2caad22b7b
--- /dev/null
+++ b/packages/frontend/src/pages/settings/custom-css.vue
@@ -0,0 +1,46 @@
+<template>
+<div class="_formRoot">
+ <FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo>
+
+ <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;">
+ <template #label>CSS</template>
+ </FormTextarea>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
+
+async function apply() {
+ localStorage.setItem('customCss', localCustomCss.value);
+
+ const { canceled } = await os.confirm({
+ type: 'info',
+ text: i18n.ts.reloadToApplySetting,
+ });
+ if (canceled) return;
+
+ unisonReload();
+}
+
+watch(localCustomCss, async () => {
+ await apply();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.customCss,
+ icon: 'ti ti-code',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
new file mode 100644
index 0000000000..82cefe05d5
--- /dev/null
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -0,0 +1,39 @@
+<template>
+<div class="_formRoot">
+ <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch>
+
+ <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch>
+
+ <FormRadios v-model="columnAlign" class="_formBlock">
+ <template #label>{{ i18n.ts._deck.columnAlign }}</template>
+ <option value="left">{{ i18n.ts.left }}</option>
+ <option value="center">{{ i18n.ts.center }}</option>
+ </FormRadios>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormLink from '@/components/form/link.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormInput from '@/components/form/input.vue';
+import { deckStore } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
+const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
+const columnAlign = computed(deckStore.makeGetterSetter('columnAlign'));
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.deck,
+ icon: 'ti ti-columns',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue
new file mode 100644
index 0000000000..8a25ff39f0
--- /dev/null
+++ b/packages/frontend/src/pages/settings/delete-account.vue
@@ -0,0 +1,52 @@
+<template>
+<div class="_formRoot">
+ <FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo>
+ <FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo>
+ <FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton>
+ <FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import FormInfo from '@/components/MkInfo.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { signout } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+async function deleteAccount() {
+ {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAccountConfirm,
+ });
+ if (canceled) return;
+ }
+
+ const { canceled, result: password } = await os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('i/delete-account', {
+ password: password,
+ });
+
+ await os.alert({
+ title: i18n.ts._accountDelete.started,
+ });
+
+ await signout();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._accountDelete.accountDelete,
+ icon: 'ti ti-alert-triangle',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
new file mode 100644
index 0000000000..2d45b1add8
--- /dev/null
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -0,0 +1,145 @@
+<template>
+<div class="_formRoot">
+ <FormSection v-if="!fetching">
+ <template #label>{{ i18n.ts.usageAmount }}</template>
+ <div class="_formBlock uawsfosz">
+ <div class="meter"><div :style="meterStyle"></div></div>
+ </div>
+ <FormSplit>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.capacity }}</template>
+ <template #value>{{ bytes(capacity, 1) }}</template>
+ </MkKeyValue>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.inUse }}</template>
+ <template #value>{{ bytes(usage, 1) }}</template>
+ </MkKeyValue>
+ </FormSplit>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.statistics }}</template>
+ <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+ </FormSection>
+
+ <FormSection>
+ <FormLink @click="chooseUploadFolder()">
+ {{ i18n.ts.uploadFolder }}
+ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
+ <template #suffixIcon><i class="fas fa-folder-open"></i></template>
+ </FormLink>
+ <FormSwitch v-model="keepOriginalUploading" class="_formBlock">
+ <template #label>{{ i18n.ts.keepOriginalUploading }}</template>
+ <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()">
+ <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()">
+ <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+ <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template>
+ </FormSwitch>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import tinycolor from 'tinycolor2';
+import FormLink from '@/components/form/link.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSection from '@/components/form/section.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import { defaultStore } from '@/store';
+import MkChart from '@/components/MkChart.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+const fetching = ref(true);
+const usage = ref<any>(null);
+const capacity = ref<any>(null);
+const uploadFolder = ref<any>(null);
+let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw);
+let autoSensitive = $ref($i.autoSensitive);
+
+const meterStyle = computed(() => {
+ return {
+ width: `${usage.value / capacity.value * 100}%`,
+ background: tinycolor({
+ h: 180 - (usage.value / capacity.value * 180),
+ s: 0.7,
+ l: 0.5,
+ }),
+ };
+});
+
+const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
+
+os.api('drive').then(info => {
+ capacity.value = info.capacity;
+ usage.value = info.usage;
+ fetching.value = false;
+});
+
+if (defaultStore.state.uploadFolder) {
+ os.api('drive/folders/show', {
+ folderId: defaultStore.state.uploadFolder,
+ }).then(response => {
+ uploadFolder.value = response;
+ });
+}
+
+function chooseUploadFolder() {
+ os.selectDriveFolder(false).then(async folder => {
+ defaultStore.set('uploadFolder', folder ? folder.id : null);
+ os.success();
+ if (defaultStore.state.uploadFolder) {
+ uploadFolder.value = await os.api('drive/folders/show', {
+ folderId: defaultStore.state.uploadFolder,
+ });
+ } else {
+ uploadFolder.value = null;
+ }
+ });
+}
+
+function saveProfile() {
+ os.api('i/update', {
+ alwaysMarkNsfw: !!alwaysMarkNsfw,
+ autoSensitive: !!autoSensitive,
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.drive,
+ icon: 'ti ti-cloud',
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.uawsfosz {
+
+ > .meter {
+ $size: 12px;
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: math.div($size, 2);
+ overflow: hidden;
+
+ > div {
+ height: $size;
+ border-radius: math.div($size, 2);
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
new file mode 100644
index 0000000000..3fff8c6b1d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -0,0 +1,111 @@
+<template>
+<div class="_formRoot">
+ <FormSection>
+ <template #label>{{ i18n.ts.emailAddress }}</template>
+ <FormInput v-model="emailAddress" type="email" manual-save>
+ <template #prefix><i class="ti ti-mail"></i></template>
+ <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template>
+ <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template>
+ </FormInput>
+ </FormSection>
+
+ <FormSection>
+ <FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail">
+ {{ i18n.ts.receiveAnnouncementFromInstance }}
+ </FormSwitch>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.emailNotification }}</template>
+ <FormSwitch v-model="emailNotification_mention" class="_formBlock">
+ {{ i18n.ts._notification._types.mention }}
+ </FormSwitch>
+ <FormSwitch v-model="emailNotification_reply" class="_formBlock">
+ {{ i18n.ts._notification._types.reply }}
+ </FormSwitch>
+ <FormSwitch v-model="emailNotification_quote" class="_formBlock">
+ {{ i18n.ts._notification._types.quote }}
+ </FormSwitch>
+ <FormSwitch v-model="emailNotification_follow" class="_formBlock">
+ {{ i18n.ts._notification._types.follow }}
+ </FormSwitch>
+ <FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock">
+ {{ i18n.ts._notification._types.receiveFollowRequest }}
+ </FormSwitch>
+ <FormSwitch v-model="emailNotification_groupInvited" class="_formBlock">
+ {{ i18n.ts._notification._types.groupInvited }}
+ </FormSwitch>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import FormSection from '@/components/form/section.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const emailAddress = ref($i!.email);
+
+const onChangeReceiveAnnouncementEmail = (v) => {
+ os.api('i/update', {
+ receiveAnnouncementEmail: v,
+ });
+};
+
+const saveEmailAddress = () => {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/update-email', {
+ password: password,
+ email: emailAddress.value,
+ });
+ });
+};
+
+const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
+const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
+const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote'));
+const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow'));
+const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest'));
+const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited'));
+
+const saveNotificationSettings = () => {
+ os.api('i/update', {
+ emailNotificationTypes: [
+ ...[emailNotification_mention.value ? 'mention' : null],
+ ...[emailNotification_reply.value ? 'reply' : null],
+ ...[emailNotification_quote.value ? 'quote' : null],
+ ...[emailNotification_follow.value ? 'follow' : null],
+ ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
+ ...[emailNotification_groupInvited.value ? 'groupInvited' : null],
+ ].filter(x => x != null),
+ });
+};
+
+watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => {
+ saveNotificationSettings();
+});
+
+onMounted(() => {
+ watch(emailAddress, () => {
+ saveEmailAddress();
+ });
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.email,
+ icon: 'ti ti-mail',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
new file mode 100644
index 0000000000..84d99d2fd7
--- /dev/null
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -0,0 +1,196 @@
+<template>
+<div class="_formRoot">
+ <FormSelect v-model="lang" class="_formBlock">
+ <template #label>{{ i18n.ts.uiLanguage }}</template>
+ <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option>
+ <template #caption>
+ <I18n :src="i18n.ts.i18nInfo" tag="span">
+ <template #link>
+ <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
+ </template>
+ </I18n>
+ </template>
+ </FormSelect>
+
+ <FormRadios v-model="overridedDeviceKind" class="_formBlock">
+ <template #label>{{ i18n.ts.overridedDeviceKind }}</template>
+ <option :value="null">{{ i18n.ts.auto }}</option>
+ <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option>
+ <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option>
+ <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option>
+ </FormRadios>
+
+ <FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.behavior }}</template>
+ <FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch>
+ <FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch>
+ <FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch>
+ <FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch>
+
+ <FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
+ <template #label>{{ i18n.ts.whenServerDisconnected }}</template>
+ <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
+ <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
+ <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
+ </FormSelect>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.appearance }}</template>
+ <FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch>
+ <FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch>
+ <FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch>
+ <FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch>
+ <FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
+ <FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
+ <FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
+ <div class="_formBlock">
+ <FormRadios v-model="emojiStyle">
+ <template #label>{{ i18n.ts.emojiStyle }}</template>
+ <option value="native">{{ i18n.ts.native }}</option>
+ <option value="fluentEmoji">Fluent Emoji</option>
+ <option value="twemoji">Twemoji</option>
+ </FormRadios>
+ <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
+ </div>
+
+ <FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
+
+ <FormRadios v-model="fontSize" class="_formBlock">
+ <template #label>{{ i18n.ts.fontSize }}</template>
+ <option :value="null"><span style="font-size: 14px;">Aa</span></option>
+ <option value="1"><span style="font-size: 15px;">Aa</span></option>
+ <option value="2"><span style="font-size: 16px;">Aa</span></option>
+ <option value="3"><span style="font-size: 17px;">Aa</span></option>
+ </FormRadios>
+ </FormSection>
+
+ <FormSection>
+ <FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch>
+ </FormSection>
+
+ <FormSelect v-model="instanceTicker" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceTicker }}</template>
+ <option value="none">{{ i18n.ts._instanceTicker.none }}</option>
+ <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
+ <option value="always">{{ i18n.ts._instanceTicker.always }}</option>
+ </FormSelect>
+
+ <FormSelect v-model="nsfw" class="_formBlock">
+ <template #label>{{ i18n.ts.nsfw }}</template>
+ <option value="respect">{{ i18n.ts._nsfw.respect }}</option>
+ <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
+ <option value="force">{{ i18n.ts._nsfw.force }}</option>
+ </FormSelect>
+
+ <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock">
+ <template #label>{{ i18n.ts.numberOfPageCache }}</template>
+ <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
+ </FormRange>
+
+ <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink>
+
+ <FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormRange from '@/components/form/range.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import MkLink from '@/components/MkLink.vue';
+import { langs } from '@/config';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const lang = ref(localStorage.getItem('lang'));
+const fontSize = ref(localStorage.getItem('fontSize'));
+const useSystemFont = ref(localStorage.getItem('useSystemFont') != null);
+
+async function reloadAsk() {
+ const { canceled } = await os.confirm({
+ type: 'info',
+ text: i18n.ts.reloadToApplySetting,
+ });
+ if (canceled) return;
+
+ unisonReload();
+}
+
+const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
+const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
+const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
+const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
+const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));
+const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'));
+const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v));
+const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
+const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
+const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
+const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
+const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
+const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
+const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript'));
+const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
+const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
+const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
+const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
+const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
+const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
+const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode'));
+
+watch(lang, () => {
+ localStorage.setItem('lang', lang.value as string);
+ localStorage.removeItem('locale');
+});
+
+watch(fontSize, () => {
+ if (fontSize.value == null) {
+ localStorage.removeItem('fontSize');
+ } else {
+ localStorage.setItem('fontSize', fontSize.value);
+ }
+});
+
+watch(useSystemFont, () => {
+ if (useSystemFont.value) {
+ localStorage.setItem('useSystemFont', 't');
+ } else {
+ localStorage.removeItem('useSystemFont');
+ }
+});
+
+watch([
+ lang,
+ fontSize,
+ useSystemFont,
+ enableInfiniteScroll,
+ squareAvatars,
+ aiChanMode,
+ showGapBetweenNotesInTimeline,
+ instanceTicker,
+ overridedDeviceKind,
+], async () => {
+ await reloadAsk();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.general,
+ icon: 'ti ti-adjustments',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
new file mode 100644
index 0000000000..7db267c142
--- /dev/null
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="_formRoot">
+ <FormSection>
+ <template #label>{{ i18n.ts._exportOrImport.allNotes }}</template>
+ <FormFolder>
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </FormFolder>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts._exportOrImport.followingList }}</template>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <FormSwitch v-model="excludeMutingUsers" class="_formBlock">
+ {{ i18n.ts._exportOrImport.excludeMutingUsers }}
+ </FormSwitch>
+ <FormSwitch v-model="excludeInactiveUsers" class="_formBlock">
+ {{ i18n.ts._exportOrImport.excludeInactiveUsers }}
+ </FormSwitch>
+ <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.import }}</template>
+ <template #icon><i class="ti ti-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+ </FormFolder>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts._exportOrImport.userLists }}</template>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.import }}</template>
+ <template #icon><i class="ti ti-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+ </FormFolder>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts._exportOrImport.muteList }}</template>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.import }}</template>
+ <template #icon><i class="ti ti-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+ </FormFolder>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts._exportOrImport.blockingList }}</template>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.import }}</template>
+ <template #icon><i class="ti ti-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+ </FormFolder>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const excludeMutingUsers = ref(false);
+const excludeInactiveUsers = ref(false);
+
+const onExportSuccess = () => {
+ os.alert({
+ type: 'info',
+ text: i18n.ts.exportRequested,
+ });
+};
+
+const onImportSuccess = () => {
+ os.alert({
+ type: 'info',
+ text: i18n.ts.importRequested,
+ });
+};
+
+const onError = (ev) => {
+ os.alert({
+ type: 'error',
+ text: ev.message,
+ });
+};
+
+const exportNotes = () => {
+ os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportFollowing = () => {
+ os.api('i/export-following', {
+ excludeMuting: excludeMutingUsers.value,
+ excludeInactive: excludeInactiveUsers.value,
+ })
+ .then(onExportSuccess).catch(onError);
+};
+
+const exportBlocking = () => {
+ os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportUserLists = () => {
+ os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
+};
+
+const exportMuting = () => {
+ os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
+};
+
+const importFollowing = async (ev) => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importUserLists = async (ev) => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importMuting = async (ev) => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const importBlocking = async (ev) => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.importAndExport,
+ icon: 'ti ti-package',
+});
+</script>
+
+<style module>
+.button {
+ margin-right: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
new file mode 100644
index 0000000000..01436cd554
--- /dev/null
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -0,0 +1,291 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+ <div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
+ <div class="body">
+ <div v-if="!narrow || currentPage?.route.name == null" class="nav">
+ <div class="baaadecd">
+ <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
+ </div>
+ </div>
+ <div v-if="!(narrow && currentPage?.route.name == null)" class="main">
+ <div class="bkzroven">
+ <RouterView/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </MkSpacer>
+</mkstickycontainer>
+</template>
+
+<script setup lang="ts">
+import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkInfo from '@/components/MkInfo.vue';
+import MkSuperMenu from '@/components/MkSuperMenu.vue';
+import { scroll } from '@/scripts/scroll';
+import { signout, $i } from '@/account';
+import { unisonReload } from '@/scripts/unison-reload';
+import { instance } from '@/instance';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import * as os from '@/os';
+
+const indexInfo = {
+ title: i18n.ts.settings,
+ icon: 'ti ti-settings',
+ hideHeader: true,
+};
+const INFO = ref(indexInfo);
+const el = ref<HTMLElement | null>(null);
+const childInfo = ref(null);
+
+const router = useRouter();
+
+let narrow = $ref(false);
+const NARROW_THRESHOLD = 600;
+
+let currentPage = $computed(() => router.currentRef.value.child);
+
+const ro = new ResizeObserver((entries, observer) => {
+ if (entries.length === 0) return;
+ narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
+});
+
+const menuDef = computed(() => [{
+ title: i18n.ts.basicSettings,
+ items: [{
+ icon: 'ti ti-user',
+ text: i18n.ts.profile,
+ to: '/settings/profile',
+ active: currentPage?.route.name === 'profile',
+ }, {
+ icon: 'ti ti-lock-open',
+ text: i18n.ts.privacy,
+ to: '/settings/privacy',
+ active: currentPage?.route.name === 'privacy',
+ }, {
+ icon: 'ti ti-mood-happy',
+ text: i18n.ts.reaction,
+ to: '/settings/reaction',
+ active: currentPage?.route.name === 'reaction',
+ }, {
+ icon: 'ti ti-cloud',
+ text: i18n.ts.drive,
+ to: '/settings/drive',
+ active: currentPage?.route.name === 'drive',
+ }, {
+ icon: 'ti ti-bell',
+ text: i18n.ts.notifications,
+ to: '/settings/notifications',
+ active: currentPage?.route.name === 'notifications',
+ }, {
+ icon: 'ti ti-mail',
+ text: i18n.ts.email,
+ to: '/settings/email',
+ active: currentPage?.route.name === 'email',
+ }, {
+ icon: 'ti ti-share',
+ text: i18n.ts.integration,
+ to: '/settings/integration',
+ active: currentPage?.route.name === 'integration',
+ }, {
+ icon: 'ti ti-lock',
+ text: i18n.ts.security,
+ to: '/settings/security',
+ active: currentPage?.route.name === 'security',
+ }],
+}, {
+ title: i18n.ts.clientSettings,
+ items: [{
+ icon: 'ti ti-adjustments',
+ text: i18n.ts.general,
+ to: '/settings/general',
+ active: currentPage?.route.name === 'general',
+ }, {
+ icon: 'ti ti-palette',
+ text: i18n.ts.theme,
+ to: '/settings/theme',
+ active: currentPage?.route.name === 'theme',
+ }, {
+ icon: 'ti ti-menu-2',
+ text: i18n.ts.navbar,
+ to: '/settings/navbar',
+ active: currentPage?.route.name === 'navbar',
+ }, {
+ icon: 'ti ti-equal-double',
+ text: i18n.ts.statusbar,
+ to: '/settings/statusbar',
+ active: currentPage?.route.name === 'statusbar',
+ }, {
+ icon: 'ti ti-music',
+ text: i18n.ts.sounds,
+ to: '/settings/sounds',
+ active: currentPage?.route.name === 'sounds',
+ }, {
+ icon: 'ti ti-plug',
+ text: i18n.ts.plugins,
+ to: '/settings/plugin',
+ active: currentPage?.route.name === 'plugin',
+ }],
+}, {
+ title: i18n.ts.otherSettings,
+ items: [{
+ icon: 'ti ti-package',
+ text: i18n.ts.importAndExport,
+ to: '/settings/import-export',
+ active: currentPage?.route.name === 'import-export',
+ }, {
+ icon: 'ti ti-planet-off',
+ text: i18n.ts.instanceMute,
+ to: '/settings/instance-mute',
+ active: currentPage?.route.name === 'instance-mute',
+ }, {
+ icon: 'ti ti-ban',
+ text: i18n.ts.muteAndBlock,
+ to: '/settings/mute-block',
+ active: currentPage?.route.name === 'mute-block',
+ }, {
+ icon: 'ti ti-message-off',
+ text: i18n.ts.wordMute,
+ to: '/settings/word-mute',
+ active: currentPage?.route.name === 'word-mute',
+ }, {
+ icon: 'ti ti-api',
+ text: 'API',
+ to: '/settings/api',
+ active: currentPage?.route.name === 'api',
+ }, {
+ icon: 'ti ti-webhook',
+ text: 'Webhook',
+ to: '/settings/webhook',
+ active: currentPage?.route.name === 'webhook',
+ }, {
+ icon: 'ti ti-dots',
+ text: i18n.ts.other,
+ to: '/settings/other',
+ active: currentPage?.route.name === 'other',
+ }],
+}, {
+ items: [{
+ icon: 'ti ti-device-floppy',
+ text: i18n.ts.preferencesBackups,
+ to: '/settings/preferences-backups',
+ active: currentPage?.route.name === 'preferences-backups',
+ }, {
+ type: 'button',
+ icon: 'ti ti-trash',
+ text: i18n.ts.clearCache,
+ action: () => {
+ localStorage.removeItem('locale');
+ localStorage.removeItem('theme');
+ unisonReload();
+ },
+ }, {
+ type: 'button',
+ icon: 'ti ti-power',
+ text: i18n.ts.logout,
+ action: async () => {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.logoutConfirm,
+ });
+ if (canceled) return;
+ signout();
+ },
+ danger: true,
+ }],
+}]);
+
+watch($$(narrow), () => {
+});
+
+onMounted(() => {
+ ro.observe(el.value);
+
+ narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+ if (!narrow && currentPage?.route.name == null) {
+ router.replace('/settings/profile');
+ }
+});
+
+onActivated(() => {
+ narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+ if (!narrow && currentPage?.route.name == null) {
+ router.replace('/settings/profile');
+ }
+});
+
+onUnmounted(() => {
+ ro.disconnect();
+});
+
+const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
+
+provideMetadataReceiver((info) => {
+ if (info == null) {
+ childInfo.value = null;
+ } else {
+ childInfo.value = info;
+ }
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+// w 890
+// h 700
+</script>
+
+<style lang="scss" scoped>
+.vvcocwet {
+ > .body {
+ > .nav {
+ .baaadecd {
+ > .info {
+ margin: 16px 0;
+ }
+
+ > .accounts {
+ > .avatar {
+ display: block;
+ width: 50px;
+ height: 50px;
+ margin: 8px auto 16px auto;
+ }
+ }
+ }
+ }
+
+ > .main {
+ .bkzroven {
+ }
+ }
+ }
+
+ &.wide {
+ > .body {
+ display: flex;
+ height: 100%;
+
+ > .nav {
+ width: 34%;
+ padding-right: 32px;
+ box-sizing: border-box;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue
new file mode 100644
index 0000000000..54504de188
--- /dev/null
+++ b/packages/frontend/src/pages/settings/instance-mute.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_formRoot">
+ <MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo>
+ <FormTextarea v-model="instanceMutes" class="_formBlock">
+ <template #label>{{ i18n.ts._instanceMute.heading }}</template>
+ <template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template>
+ </FormTextarea>
+ <MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const instanceMutes = ref($i!.mutedInstances.join('\n'));
+const changed = ref(false);
+
+async function save() {
+ let mutes = instanceMutes.value
+ .trim().split('\n')
+ .map(el => el.trim())
+ .filter(el => el);
+
+ await os.api('i/update', {
+ mutedInstances: mutes,
+ });
+
+ changed.value = false;
+
+ // Refresh filtered list to signal to the user how they've been saved
+ instanceMutes.value = mutes.join('\n');
+}
+
+watch(instanceMutes, () => {
+ changed.value = true;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.instanceMute,
+ icon: 'ti ti-planet-off',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue
new file mode 100644
index 0000000000..557fe778e6
--- /dev/null
+++ b/packages/frontend/src/pages/settings/integration.vue
@@ -0,0 +1,99 @@
+<template>
+<div class="_formRoot">
+ <FormSection v-if="instance.enableTwitterIntegration">
+ <template #label><i class="ti ti-brand-twitter"></i> Twitter</template>
+ <p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
+ <MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton>
+ <MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton>
+ </FormSection>
+
+ <FormSection v-if="instance.enableDiscordIntegration">
+ <template #label><i class="ti ti-brand-discord"></i> Discord</template>
+ <p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
+ <MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton>
+ <MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton>
+ </FormSection>
+
+ <FormSection v-if="instance.enableGithubIntegration">
+ <template #label><i class="ti ti-brand-github"></i> GitHub</template>
+ <p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
+ <MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton>
+ <MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { apiUrl } from '@/config';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import { $i } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const twitterForm = ref<Window | null>(null);
+const discordForm = ref<Window | null>(null);
+const githubForm = ref<Window | null>(null);
+
+const integrations = computed(() => $i!.integrations);
+
+function openWindow(service: string, type: string) {
+ return window.open(`${apiUrl}/${type}/${service}`,
+ `${service}_${type}_window`,
+ 'height=570, width=520',
+ );
+}
+
+function connectTwitter() {
+ twitterForm.value = openWindow('twitter', 'connect');
+}
+
+function disconnectTwitter() {
+ openWindow('twitter', 'disconnect');
+}
+
+function connectDiscord() {
+ discordForm.value = openWindow('discord', 'connect');
+}
+
+function disconnectDiscord() {
+ openWindow('discord', 'disconnect');
+}
+
+function connectGithub() {
+ githubForm.value = openWindow('github', 'connect');
+}
+
+function disconnectGithub() {
+ openWindow('github', 'disconnect');
+}
+
+onMounted(() => {
+ document.cookie = `igi=${$i!.token}; path=/;` +
+ ' max-age=31536000;' +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+
+ watch(integrations, () => {
+ if (integrations.value.twitter) {
+ if (twitterForm.value) twitterForm.value.close();
+ }
+ if (integrations.value.discord) {
+ if (discordForm.value) discordForm.value.close();
+ }
+ if (integrations.value.github) {
+ if (githubForm.value) githubForm.value.close();
+ }
+ });
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.integration,
+ icon: 'ti ti-share',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..1cf33d34db
--- /dev/null
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -0,0 +1,61 @@
+<template>
+<div class="_formRoot">
+ <MkTab v-model="tab" style="margin-bottom: var(--margin);">
+ <option value="mute">{{ i18n.ts.mutedUsers }}</option>
+ <option value="block">{{ i18n.ts.blockedUsers }}</option>
+ </MkTab>
+ <div v-if="tab === 'mute'">
+ <MkPagination :pagination="mutingPagination" class="muting">
+ <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
+ <MkAcct :user="mute.mutee"/>
+ </FormLink>
+ </template>
+ </MkPagination>
+ </div>
+ <div v-if="tab === 'block'">
+ <MkPagination :pagination="blockingPagination" class="blocking">
+ <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
+ <MkAcct :user="block.blockee"/>
+ </FormLink>
+ </template>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkTab from '@/components/MkTab.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormLink from '@/components/form/link.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let tab = $ref('mute');
+
+const mutingPagination = {
+ endpoint: 'mute/list' as const,
+ limit: 10,
+};
+
+const blockingPagination = {
+ endpoint: 'blocking/list' as const,
+ limit: 10,
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.muteAndBlock,
+ icon: 'ti ti-ban',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
new file mode 100644
index 0000000000..0b2776ec90
--- /dev/null
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -0,0 +1,87 @@
+<template>
+<div class="_formRoot">
+ <FormTextarea v-model="items" tall manual-save class="_formBlock">
+ <template #label>{{ i18n.ts.navbar }}</template>
+ <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template>
+ </FormTextarea>
+
+ <FormRadios v-model="menuDisplay" class="_formBlock">
+ <template #label>{{ i18n.ts.display }}</template>
+ <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option>
+ <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option>
+ <option value="top">{{ i18n.ts._menuDisplay.top }}</option>
+ <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
+ </FormRadios>
+
+ <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { navbarItemDef } from '@/navbar';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const items = ref(defaultStore.state.menu.join('\n'));
+
+const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== ''));
+const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+
+async function reloadAsk() {
+ const { canceled } = await os.confirm({
+ type: 'info',
+ text: i18n.ts.reloadToApplySetting,
+ });
+ if (canceled) return;
+
+ unisonReload();
+}
+
+async function addItem() {
+ const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k));
+ const { canceled, result: item } = await os.select({
+ title: i18n.ts.addItem,
+ items: [...menu.map(k => ({
+ value: k, text: i18n.ts[navbarItemDef[k].title],
+ })), {
+ value: '-', text: i18n.ts.divider,
+ }],
+ });
+ if (canceled) return;
+ items.value = [...split.value, item].join('\n');
+}
+
+async function save() {
+ defaultStore.set('menu', split.value);
+ await reloadAsk();
+}
+
+function reset() {
+ defaultStore.reset('menu');
+ items.value = defaultStore.state.menu.join('\n');
+}
+
+watch(items, async () => {
+ await save();
+});
+
+watch(menuDisplay, async () => {
+ await reloadAsk();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.navbar,
+ icon: 'ti ti-list',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
new file mode 100644
index 0000000000..e85fede157
--- /dev/null
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -0,0 +1,90 @@
+<template>
+<div class="_formRoot">
+ <FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink>
+ <FormSection>
+ <FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
+ <FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
+ <FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts.pushNotification }}</template>
+ <MkPushNotificationAllowButton ref="allowButton" />
+ <FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage">
+ <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template>
+ <template #caption>
+ <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption">
+ <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template>
+ </I18n>
+ </template>
+ </FormSwitch>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { notificationTypes } from 'misskey-js';
+import FormButton from '@/components/MkButton.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
+
+let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>();
+let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer);
+let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false);
+
+async function readAllUnreadNotes() {
+ await os.api('i/read-all-unread-notes');
+}
+
+async function readAllMessagingMessages() {
+ await os.api('i/read-all-messaging-messages');
+}
+
+async function readAllNotifications() {
+ await os.api('notifications/mark-all-as-read');
+}
+
+function configure() {
+ const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x));
+ os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
+ includingTypes,
+ showGlobalToggle: false,
+ }, {
+ done: async (res) => {
+ const { includingTypes: value } = res;
+ await os.apiWithDialog('i/update', {
+ mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
+ }).then(i => {
+ $i!.mutingNotificationTypes = i.mutingNotificationTypes;
+ });
+ },
+ }, 'closed');
+}
+
+function onChangeSendReadMessage(v: boolean) {
+ if (!pushRegistrationInServer) return;
+
+ os.apiWithDialog('sw/update-registration', {
+ endpoint: pushRegistrationInServer.endpoint,
+ sendReadMessage: v,
+ }).then(res => {
+ if (!allowButton) return;
+ allowButton.pushRegistrationInServer = res;
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.notifications,
+ icon: 'ti ti-bell',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
new file mode 100644
index 0000000000..40bb202789
--- /dev/null
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="_formRoot">
+ <FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote">
+ {{ i18n.ts.showFeaturedNotesInTimeline }}
+ </FormSwitch>
+
+ <!--
+ <FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch>
+ -->
+
+ <FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink>
+
+ <FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
+
+ <FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormLink from '@/components/form/link.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const reportError = computed(defaultStore.makeGetterSetter('reportError'));
+
+function onChangeInjectFeaturedNote(v) {
+ os.api('i/update', {
+ injectFeaturedNote: v,
+ }).then((i) => {
+ $i!.injectFeaturedNote = i.injectFeaturedNote;
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.other,
+ icon: 'ti ti-dots',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue
new file mode 100644
index 0000000000..550bba242e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/plugin.install.vue
@@ -0,0 +1,124 @@
+<template>
+<div class="_formRoot">
+ <FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo>
+
+ <FormTextarea v-model="code" tall class="_formBlock">
+ <template #label>{{ i18n.ts.code }}</template>
+ </FormTextarea>
+
+ <div class="_formBlock">
+ <FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, nextTick, ref } from 'vue';
+import { AiScript, parse } from '@syuilo/aiscript';
+import { serialize } from '@syuilo/aiscript/built/serializer';
+import { v4 as uuid } from 'uuid';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const code = ref(null);
+
+function installPlugin({ id, meta, ast, token }) {
+ ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
+ ...meta,
+ id,
+ active: true,
+ configData: {},
+ token: token,
+ ast: ast,
+ }));
+}
+
+async function install() {
+ let ast;
+ try {
+ ast = parse(code.value);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: 'Syntax error :(',
+ });
+ return;
+ }
+
+ const meta = AiScript.collectMetadata(ast);
+ if (meta == null) {
+ os.alert({
+ type: 'error',
+ text: 'No metadata found :(',
+ });
+ return;
+ }
+
+ const metadata = meta.get(null);
+ if (metadata == null) {
+ os.alert({
+ type: 'error',
+ text: 'No metadata found :(',
+ });
+ return;
+ }
+
+ const { name, version, author, description, permissions, config } = metadata;
+ if (name == null || version == null || author == null) {
+ os.alert({
+ type: 'error',
+ text: 'Required property not found :(',
+ });
+ return;
+ }
+
+ const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
+ title: i18n.ts.tokenRequested,
+ information: i18n.ts.pluginTokenRequestedDescription,
+ initialName: name,
+ initialPermissions: permissions,
+ }, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+ res(token);
+ },
+ }, 'closed');
+ });
+
+ installPlugin({
+ id: uuid(),
+ meta: {
+ name, version, author, description, permissions, config,
+ },
+ token,
+ ast: serialize(ast),
+ });
+
+ os.success();
+
+ nextTick(() => {
+ unisonReload();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._plugin.install,
+ icon: 'ti ti-download',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
new file mode 100644
index 0000000000..905efd833d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="_formRoot">
+ <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.manage }}</template>
+ <div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
+ <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
+
+ <FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
+
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.author }}</template>
+ <template #value>{{ plugin.author }}</template>
+ </MkKeyValue>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.description }}</template>
+ <template #value>{{ plugin.description }}</template>
+ </MkKeyValue>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.permission }}</template>
+ <template #value>{{ plugin.permission }}</template>
+ </MkKeyValue>
+
+ <div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
+ <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
+ </div>
+ </div>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, ref } from 'vue';
+import FormLink from '@/components/form/link.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const plugins = ref(ColdDeviceStorage.get('plugins'));
+
+function uninstall(plugin) {
+ ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id));
+ os.success();
+ nextTick(() => {
+ unisonReload();
+ });
+}
+
+// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
+async function config(plugin) {
+ const config = plugin.config;
+ for (const key in plugin.configData) {
+ config[key].default = plugin.configData[key];
+ }
+
+ const { canceled, result } = await os.form(plugin.name, config);
+ if (canceled) return;
+
+ const coldPlugins = ColdDeviceStorage.get('plugins');
+ coldPlugins.find(p => p.id === plugin.id)!.configData = result;
+ ColdDeviceStorage.set('plugins', coldPlugins);
+
+ nextTick(() => {
+ location.reload();
+ });
+}
+
+function changeActive(plugin, active) {
+ const coldPlugins = ColdDeviceStorage.get('plugins');
+ coldPlugins.find(p => p.id === plugin.id)!.active = active;
+ ColdDeviceStorage.set('plugins', coldPlugins);
+
+ nextTick(() => {
+ location.reload();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.plugins,
+ icon: 'ti ti-plug',
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
new file mode 100644
index 0000000000..f427a170c4
--- /dev/null
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -0,0 +1,444 @@
+<template>
+<div class="_formRoot">
+ <div :class="$style.buttons">
+ <MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton>
+ <MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton>
+ </div>
+
+ <FormSection>
+ <template #label>{{ ts._preferencesBackups.list }}</template>
+ <template v-if="profiles && Object.keys(profiles).length > 0">
+ <div
+ v-for="(profile, id) in profiles"
+ :key="id"
+ class="_formBlock _panel"
+ :class="$style.profile"
+ @click="$event => menu($event, id)"
+ @contextmenu.prevent.stop="$event => menu($event, id)"
+ >
+ <div :class="$style.profileName">{{ profile.name }}</div>
+ <div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div>
+ <div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div>
+ </div>
+ </template>
+ <div v-else-if="profiles">
+ <MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo>
+ </div>
+ <MkLoading v-else/>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, useCssModule } from 'vue';
+import { v4 as uuid } from 'uuid';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { version, host } from '@/config';
+import { definePageMetadata } from '@/scripts/page-metadata';
+const { t, ts } = i18n;
+
+useCssModule();
+
+const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
+ 'menu',
+ 'visibility',
+ 'localOnly',
+ 'statusbars',
+ 'widgets',
+ 'tl',
+ 'overridedDeviceKind',
+ 'serverDisconnectedBehavior',
+ 'nsfw',
+ 'animation',
+ 'animatedMfm',
+ 'loadRawImages',
+ 'imageNewTab',
+ 'disableShowingAnimatedImages',
+ 'disablePagesScript',
+ 'emojiStyle',
+ 'disableDrawer',
+ 'useBlurEffectForModal',
+ 'useBlurEffect',
+ 'showFixedPostForm',
+ 'enableInfiniteScroll',
+ 'useReactionPickerForContextMenu',
+ 'showGapBetweenNotesInTimeline',
+ 'instanceTicker',
+ 'reactionPickerSize',
+ 'reactionPickerWidth',
+ 'reactionPickerHeight',
+ 'reactionPickerUseDrawerForMobile',
+ 'defaultSideView',
+ 'menuDisplay',
+ 'reportError',
+ 'squareAvatars',
+ 'numberOfPageCache',
+ 'aiChanMode',
+];
+const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
+ 'lightTheme',
+ 'darkTheme',
+ 'syncDeviceDarkMode',
+ 'plugins',
+ 'mediaVolume',
+ 'sound_masterVolume',
+ 'sound_note',
+ 'sound_noteMy',
+ 'sound_notification',
+ 'sound_chat',
+ 'sound_chatBg',
+ 'sound_antenna',
+ 'sound_channel',
+];
+
+const scope = ['clientPreferencesProfiles'];
+
+const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings'];
+
+type Profile = {
+ name: string;
+ createdAt: string;
+ updatedAt: string | null;
+ misskeyVersion: string;
+ host: string;
+ settings: {
+ hot: Record<keyof typeof defaultStoreSaveKeys, unknown>;
+ cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
+ fontSize: string | null;
+ useSystemFont: 't' | null;
+ wallpaper: string | null;
+ };
+};
+
+const connection = $i && stream.useChannel('main');
+
+let profiles = $ref<Record<string, Profile> | null>(null);
+
+os.api('i/registry/get-all', { scope })
+ .then(res => {
+ profiles = res || {};
+ });
+
+function isObject(value: unknown): value is Record<string, unknown> {
+ return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function validate(profile: unknown): void {
+ if (!isObject(profile)) throw new Error('not an object');
+
+ // Check if unnecessary properties exist
+ if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist');
+
+ if (!profile.name) throw new Error('Missing required prop: name');
+ if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion');
+
+ // Check if createdAt and updatedAt is Date
+ // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date
+ if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date');
+ if (profile.updatedAt) {
+ if (Number.isNaN(new Date(profile.updatedAt).getTime())) {
+ throw new Error('updatedAt is not Date');
+ }
+ } else if (profile.updatedAt !== null) {
+ throw new Error('updatedAt is not null');
+ }
+
+ if (!profile.settings) throw new Error('Missing required prop: settings');
+ if (!isObject(profile.settings)) throw new Error('Invalid prop: settings');
+}
+
+function getSettings(): Profile['settings'] {
+ const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>;
+ for (const key of defaultStoreSaveKeys) {
+ hot[key] = defaultStore.state[key];
+ }
+
+ const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
+ for (const key of coldDeviceStorageSaveKeys) {
+ cold[key] = ColdDeviceStorage.get(key);
+ }
+
+ return {
+ hot,
+ cold,
+ fontSize: localStorage.getItem('fontSize'),
+ useSystemFont: localStorage.getItem('useSystemFont') as 't' | null,
+ wallpaper: localStorage.getItem('wallpaper'),
+ };
+}
+
+async function saveNew(): Promise<void> {
+ if (!profiles) return;
+
+ const { canceled, result: name } = await os.inputText({
+ title: ts._preferencesBackups.inputName,
+ });
+ if (canceled) return;
+
+ if (Object.values(profiles).some(x => x.name === name)) {
+ return os.alert({
+ title: ts._preferencesBackups.cannotSave,
+ text: t('_preferencesBackups.nameAlreadyExists', { name }),
+ });
+ }
+
+ const id = uuid();
+ const profile: Profile = {
+ name,
+ createdAt: (new Date()).toISOString(),
+ updatedAt: null,
+ misskeyVersion: version,
+ host,
+ settings: getSettings(),
+ };
+ await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+}
+
+function loadFile(): void {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = false;
+ input.onchange = async () => {
+ if (!profiles) return;
+ if (!input.files || input.files.length === 0) return;
+
+ const file = input.files[0];
+
+ if (file.type !== 'application/json') {
+ return os.alert({
+ type: 'error',
+ title: ts._preferencesBackups.cannotLoad,
+ text: ts._preferencesBackups.invalidFile,
+ });
+ }
+
+ let profile: Profile;
+ try {
+ profile = JSON.parse(await file.text()) as unknown as Profile;
+ validate(profile);
+ } catch (err) {
+ return os.alert({
+ type: 'error',
+ title: ts._preferencesBackups.cannotLoad,
+ text: err?.message,
+ });
+ }
+
+ const id = uuid();
+ await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+
+ // 一応廃棄
+ (window as any).__misskey_input_ref__ = null;
+ };
+
+ // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+ // iOS Safari で正常に動かす為のおまじない
+ (window as any).__misskey_input_ref__ = input;
+
+ input.click();
+}
+
+async function applyProfile(id: string): Promise<void> {
+ if (!profiles) return;
+
+ const profile = profiles[id];
+
+ const { canceled: cancel1 } = await os.confirm({
+ type: 'warning',
+ title: ts._preferencesBackups.apply,
+ text: t('_preferencesBackups.applyConfirm', { name: profile.name }),
+ });
+ if (cancel1) return;
+
+ // TODO: バージョン or ホストが違ったらさらに警告を表示
+
+ const settings = profile.settings;
+
+ // defaultStore
+ for (const key of defaultStoreSaveKeys) {
+ if (settings.hot[key] !== undefined) {
+ defaultStore.set(key, settings.hot[key]);
+ }
+ }
+
+ // coldDeviceStorage
+ for (const key of coldDeviceStorageSaveKeys) {
+ if (settings.cold[key] !== undefined) {
+ ColdDeviceStorage.set(key, settings.cold[key]);
+ }
+ }
+
+ // fontSize
+ if (settings.fontSize) {
+ localStorage.setItem('fontSize', settings.fontSize);
+ } else {
+ localStorage.removeItem('fontSize');
+ }
+
+ // useSystemFont
+ if (settings.useSystemFont) {
+ localStorage.setItem('useSystemFont', settings.useSystemFont);
+ } else {
+ localStorage.removeItem('useSystemFont');
+ }
+
+ // wallpaper
+ if (settings.wallpaper != null) {
+ localStorage.setItem('wallpaper', settings.wallpaper);
+ } else {
+ localStorage.removeItem('wallpaper');
+ }
+
+ const { canceled: cancel2 } = await os.confirm({
+ type: 'info',
+ text: ts.reloadToApplySetting,
+ });
+ if (cancel2) return;
+
+ unisonReload();
+}
+
+async function deleteProfile(id: string): Promise<void> {
+ if (!profiles) return;
+
+ const { canceled } = await os.confirm({
+ type: 'info',
+ title: ts.delete,
+ text: t('deleteAreYouSure', { x: profiles[id].name }),
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('i/registry/remove', { scope, key: id });
+ delete profiles[id];
+}
+
+async function save(id: string): Promise<void> {
+ if (!profiles) return;
+
+ const { name, createdAt } = profiles[id];
+
+ const { canceled } = await os.confirm({
+ type: 'info',
+ title: ts._preferencesBackups.save,
+ text: t('_preferencesBackups.saveConfirm', { name }),
+ });
+ if (canceled) return;
+
+ const profile: Profile = {
+ name,
+ createdAt,
+ updatedAt: (new Date()).toISOString(),
+ misskeyVersion: version,
+ host,
+ settings: getSettings(),
+ };
+ await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile });
+}
+
+async function rename(id: string): Promise<void> {
+ if (!profiles) return;
+
+ const { canceled: cancel1, result: name } = await os.inputText({
+ title: ts._preferencesBackups.inputName,
+ });
+ if (cancel1 || profiles[id].name === name) return;
+
+ if (Object.values(profiles).some(x => x.name === name)) {
+ return os.alert({
+ title: ts._preferencesBackups.cannotSave,
+ text: t('_preferencesBackups.nameAlreadyExists', { name }),
+ });
+ }
+
+ const registry = Object.assign({}, { ...profiles[id] });
+
+ const { canceled: cancel2 } = await os.confirm({
+ type: 'info',
+ title: ts._preferencesBackups.rename,
+ text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }),
+ });
+ if (cancel2) return;
+
+ registry.name = name;
+ await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry });
+}
+
+function menu(ev: MouseEvent, profileId: string) {
+ if (!profiles) return;
+
+ return os.popupMenu([{
+ text: ts._preferencesBackups.apply,
+ icon: 'ti ti-check',
+ action: () => applyProfile(profileId),
+ }, {
+ type: 'a',
+ text: ts.download,
+ icon: 'ti ti-download',
+ href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })),
+ download: `${profiles[profileId].name}.json`,
+ }, null, {
+ text: ts.rename,
+ icon: 'ti ti-forms',
+ action: () => rename(profileId),
+ }, {
+ text: ts._preferencesBackups.save,
+ icon: 'ti ti-device-floppy',
+ action: () => save(profileId),
+ }, null, {
+ text: ts._preferencesBackups.delete,
+ icon: 'ti ti-trash',
+ action: () => deleteProfile(profileId),
+ danger: true,
+ }], ev.currentTarget ?? ev.target);
+}
+
+onMounted(() => {
+ // streamingのuser storage updateイベントを監視して更新
+ connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => {
+ if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return;
+ if (!profiles) return;
+
+ profiles[key] = value;
+ });
+});
+
+onUnmounted(() => {
+ connection?.off('registryUpdated');
+});
+
+definePageMetadata(computed(() => ({
+ title: ts.preferencesBackups,
+ icon: 'ti ti-device-floppy',
+ bg: 'var(--bg)',
+})));
+</script>
+
+<style lang="scss" module>
+.buttons {
+ display: flex;
+ gap: var(--margin);
+ flex-wrap: wrap;
+}
+
+.profile {
+ padding: 20px;
+ cursor: pointer;
+
+ &Name {
+ font-weight: 700;
+ }
+
+ &Time {
+ font-size: .85em;
+ opacity: .7;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
new file mode 100644
index 0000000000..915ca05767
--- /dev/null
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="_formRoot">
+ <FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch>
+ <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch>
+
+ <FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()">
+ {{ i18n.ts.makeReactionsPublic }}
+ <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
+ </FormSwitch>
+
+ <FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()">
+ <template #label>{{ i18n.ts.ffVisibility }}</template>
+ <option value="public">{{ i18n.ts._ffVisibility.public }}</option>
+ <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
+ <option value="private">{{ i18n.ts._ffVisibility.private }}</option>
+ <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template>
+ </FormSelect>
+
+ <FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()">
+ {{ i18n.ts.hideOnlineStatus }}
+ <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()">
+ {{ i18n.ts.noCrawle }}
+ <template #caption>{{ i18n.ts.noCrawleDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()">
+ {{ i18n.ts.makeExplorable }}
+ <template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
+ </FormSwitch>
+
+ <FormSection>
+ <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch>
+ <FormFolder v-if="!rememberNoteVisibility" class="_formBlock">
+ <template #label>{{ i18n.ts.defaultNoteVisibility }}</template>
+ <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
+ <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
+ <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template>
+ <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template>
+
+ <FormSelect v-model="defaultNoteVisibility" class="_formBlock">
+ <option value="public">{{ i18n.ts._visibility.public }}</option>
+ <option value="home">{{ i18n.ts._visibility.home }}</option>
+ <option value="followers">{{ i18n.ts._visibility.followers }}</option>
+ <option value="specified">{{ i18n.ts._visibility.specified }}</option>
+ </FormSelect>
+ <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch>
+ </FormFolder>
+ </FormSection>
+
+ <FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let isLocked = $ref($i.isLocked);
+let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
+let noCrawle = $ref($i.noCrawle);
+let isExplorable = $ref($i.isExplorable);
+let hideOnlineStatus = $ref($i.hideOnlineStatus);
+let publicReactions = $ref($i.publicReactions);
+let ffVisibility = $ref($i.ffVisibility);
+
+let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility'));
+let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly'));
+let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility'));
+let keepCw = $computed(defaultStore.makeGetterSetter('keepCw'));
+
+function save() {
+ os.api('i/update', {
+ isLocked: !!isLocked,
+ autoAcceptFollowed: !!autoAcceptFollowed,
+ noCrawle: !!noCrawle,
+ isExplorable: !!isExplorable,
+ hideOnlineStatus: !!hideOnlineStatus,
+ publicReactions: !!publicReactions,
+ ffVisibility: ffVisibility,
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.privacy,
+ icon: 'ti ti-lock-open',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
new file mode 100644
index 0000000000..14eeeaaa11
--- /dev/null
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -0,0 +1,220 @@
+<template>
+<div class="_formRoot">
+ <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
+ <div class="avatar">
+ <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/>
+ <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
+ </div>
+ <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
+ </div>
+
+ <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock">
+ <template #label>{{ i18n.ts._profile.name }}</template>
+ </FormInput>
+
+ <FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock">
+ <template #label>{{ i18n.ts._profile.description }}</template>
+ <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="profile.location" manual-save class="_formBlock">
+ <template #label>{{ i18n.ts.location }}</template>
+ <template #prefix><i class="ti ti-map-pin"></i></template>
+ </FormInput>
+
+ <FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock">
+ <template #label>{{ i18n.ts.birthday }}</template>
+ <template #prefix><i class="ti ti-cake"></i></template>
+ </FormInput>
+
+ <FormSelect v-model="profile.lang" class="_formBlock">
+ <template #label>{{ i18n.ts.language }}</template>
+ <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option>
+ </FormSelect>
+
+ <FormSlot class="_formBlock">
+ <FormFolder>
+ <template #icon><i class="ti ti-list"></i></template>
+ <template #label>{{ i18n.ts._profile.metadataEdit }}</template>
+
+ <div class="_formRoot">
+ <FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock">
+ <FormInput v-model="record.name" small>
+ <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template>
+ </FormInput>
+ <FormInput v-model="record.value" small>
+ <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template>
+ </FormInput>
+ </FormSplit>
+ <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
+ </FormFolder>
+ <template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
+ </FormSlot>
+
+ <FormFolder>
+ <template #label>{{ i18n.ts.advancedSettings }}</template>
+
+ <div class="_formRoot">
+ <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
+ <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
+ </div>
+ </FormFolder>
+
+ <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormSlot from '@/components/form/slot.vue';
+import { host } from '@/config';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { langmap } from '@/scripts/langmap';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const profile = reactive({
+ name: $i.name,
+ description: $i.description,
+ location: $i.location,
+ birthday: $i.birthday,
+ lang: $i.lang,
+ isBot: $i.isBot,
+ isCat: $i.isCat,
+ showTimelineReplies: $i.showTimelineReplies,
+});
+
+watch(() => profile, () => {
+ save();
+}, {
+ deep: true,
+});
+
+const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value })));
+
+function addField() {
+ fields.push({
+ name: '',
+ value: '',
+ });
+}
+
+while (fields.length < 4) {
+ addField();
+}
+
+function saveFields() {
+ os.apiWithDialog('i/update', {
+ fields: fields.filter(field => field.name !== '' && field.value !== ''),
+ });
+}
+
+function save() {
+ os.apiWithDialog('i/update', {
+ name: profile.name || null,
+ description: profile.description || null,
+ location: profile.location || null,
+ birthday: profile.birthday || null,
+ lang: profile.lang || null,
+ isBot: !!profile.isBot,
+ isCat: !!profile.isCat,
+ showTimelineReplies: !!profile.showTimelineReplies,
+ });
+}
+
+function changeAvatar(ev) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => {
+ let originalOrCropped = file;
+
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.t('cropImageAsk'),
+ });
+
+ if (!canceled) {
+ originalOrCropped = await os.cropImage(file, {
+ aspectRatio: 1,
+ });
+ }
+
+ const i = await os.apiWithDialog('i/update', {
+ avatarId: originalOrCropped.id,
+ });
+ $i.avatarId = i.avatarId;
+ $i.avatarUrl = i.avatarUrl;
+ });
+}
+
+function changeBanner(ev) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => {
+ let originalOrCropped = file;
+
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.t('cropImageAsk'),
+ });
+
+ if (!canceled) {
+ originalOrCropped = await os.cropImage(file, {
+ aspectRatio: 2,
+ });
+ }
+
+ const i = await os.apiWithDialog('i/update', {
+ bannerId: originalOrCropped.id,
+ });
+ $i.bannerId = i.bannerId;
+ $i.bannerUrl = i.bannerUrl;
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.profile,
+ icon: 'ti ti-user',
+});
+</script>
+
+<style lang="scss" scoped>
+.llvierxe {
+ position: relative;
+ background-size: cover;
+ background-position: center;
+ border: solid 1px var(--divider);
+ border-radius: 10px;
+ overflow: clip;
+
+ > .avatar {
+ display: inline-block;
+ text-align: center;
+ padding: 16px;
+
+ > .avatar {
+ display: inline-block;
+ width: 72px;
+ height: 72px;
+ margin: 0 auto 16px auto;
+ }
+ }
+
+ > .bannerEdit {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue
new file mode 100644
index 0000000000..2748cd7d4e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/reaction.vue
@@ -0,0 +1,154 @@
+<template>
+<div class="_formRoot">
+ <FromSlot class="_formBlock">
+ <template #label>{{ i18n.ts.reactionSettingDescription }}</template>
+ <div v-panel style="border-radius: 6px;">
+ <Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true">
+ <template #item="{element}">
+ <button class="_button item" @click="remove(element, $event)">
+ <MkEmoji :emoji="element" :normal="true"/>
+ </button>
+ </template>
+ <template #footer>
+ <button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
+ </template>
+ </Sortable>
+ </div>
+ <template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
+ </FromSlot>
+
+ <FormRadios v-model="reactionPickerSize" class="_formBlock">
+ <template #label>{{ i18n.ts.size }}</template>
+ <option :value="1">{{ i18n.ts.small }}</option>
+ <option :value="2">{{ i18n.ts.medium }}</option>
+ <option :value="3">{{ i18n.ts.large }}</option>
+ </FormRadios>
+ <FormRadios v-model="reactionPickerWidth" class="_formBlock">
+ <template #label>{{ i18n.ts.numberOfColumn }}</template>
+ <option :value="1">5</option>
+ <option :value="2">6</option>
+ <option :value="3">7</option>
+ <option :value="4">8</option>
+ <option :value="5">9</option>
+ </FormRadios>
+ <FormRadios v-model="reactionPickerHeight" class="_formBlock">
+ <template #label>{{ i18n.ts.height }}</template>
+ <option :value="1">{{ i18n.ts.small }}</option>
+ <option :value="2">{{ i18n.ts.medium }}</option>
+ <option :value="3">{{ i18n.ts.large }}</option>
+ <option :value="4">{{ i18n.ts.large }}+</option>
+ </FormRadios>
+
+ <FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock">
+ {{ i18n.ts.useDrawerReactionPickerForMobile }}
+ <template #caption>{{ i18n.ts.needReloadToApply }}</template>
+ </FormSwitch>
+
+ <FormSection>
+ <div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
+ <FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+ </div>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, watch } from 'vue';
+import Sortable from 'vuedraggable';
+import FormInput from '@/components/form/input.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FromSlot from '@/components/form/slot.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { deepClone } from '@/scripts/clone';
+
+let reactions = $ref(deepClone(defaultStore.state.reactions));
+
+const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize'));
+const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
+const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
+const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
+
+function save() {
+ defaultStore.set('reactions', reactions);
+}
+
+function remove(reaction, ev: MouseEvent) {
+ os.popupMenu([{
+ text: i18n.ts.remove,
+ action: () => {
+ reactions = reactions.filter(x => x !== reaction);
+ },
+ }], ev.currentTarget ?? ev.target);
+}
+
+function preview(ev: MouseEvent) {
+ os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+ asReactionPicker: true,
+ src: ev.currentTarget ?? ev.target,
+ }, {}, 'closed');
+}
+
+async function setDefault() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.resetAreYouSure,
+ });
+ if (canceled) return;
+
+ reactions = deepClone(defaultStore.def.reactions.default);
+}
+
+function chooseEmoji(ev: MouseEvent) {
+ os.pickEmoji(ev.currentTarget ?? ev.target, {
+ showPinned: false,
+ }).then(emoji => {
+ if (!reactions.includes(emoji)) {
+ reactions.push(emoji);
+ }
+ });
+}
+
+watch($$(reactions), () => {
+ save();
+}, {
+ deep: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.reaction,
+ icon: 'ti ti-mood-happy',
+ action: {
+ icon: 'ti ti-eye',
+ handler: preview,
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.zoaiodol {
+ padding: 12px;
+ font-size: 1.1em;
+
+ > .item {
+ display: inline-block;
+ padding: 8px;
+ cursor: move;
+ }
+
+ > .add {
+ display: inline-block;
+ padding: 8px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue
new file mode 100644
index 0000000000..33f49eb3ef
--- /dev/null
+++ b/packages/frontend/src/pages/settings/security.vue
@@ -0,0 +1,160 @@
+<template>
+<div class="_formRoot">
+ <FormSection>
+ <template #label>{{ i18n.ts.password }}</template>
+ <FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.twoStepAuthentication }}</template>
+ <X2fa/>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.signinHistory }}</template>
+ <MkPagination :pagination="pagination" disable-auto-load>
+ <template #default="{items}">
+ <div>
+ <div v-for="item in items" :key="item.id" v-panel class="timnmucd">
+ <header>
+ <i v-if="item.success" class="ti ti-check icon succ"></i>
+ <i v-else class="ti ti-circle-x icon fail"></i>
+ <code class="ip _monospace">{{ item.ip }}</code>
+ <MkTime :time="item.createdAt" class="time"/>
+ </header>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+ </FormSection>
+
+ <FormSection>
+ <FormSlot>
+ <FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton>
+ <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template>
+ </FormSlot>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import X2fa from './2fa.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSlot from '@/components/form/slot.vue';
+import FormButton from '@/components/MkButton.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+ endpoint: 'i/signin-history' as const,
+ limit: 5,
+};
+
+async function change() {
+ const { canceled: canceled1, result: currentPassword } = await os.inputText({
+ title: i18n.ts.currentPassword,
+ type: 'password',
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await os.inputText({
+ title: i18n.ts.newPassword,
+ type: 'password',
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await os.inputText({
+ title: i18n.ts.newPasswordRetype,
+ type: 'password',
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.retypedNotMatch,
+ });
+ return;
+ }
+
+ os.apiWithDialog('i/change-password', {
+ currentPassword,
+ newPassword,
+ });
+}
+
+function regenerateToken() {
+ os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/regenerate_token', {
+ password: password,
+ });
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.security,
+ icon: 'ti ti-lock',
+});
+</script>
+
+<style lang="scss" scoped>
+.timnmucd {
+ padding: 16px;
+
+ &:first-child {
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ }
+
+ &:last-child {
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ }
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > header {
+ display: flex;
+ align-items: center;
+
+ > .icon {
+ width: 1em;
+ margin-right: 0.75em;
+
+ &.succ {
+ color: var(--success);
+ }
+
+ &.fail {
+ color: var(--error);
+ }
+ }
+
+ > .ip {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 12px;
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
new file mode 100644
index 0000000000..62627c6333
--- /dev/null
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="_formRoot">
+ <FormSelect v-model="type">
+ <template #label>{{ i18n.ts.sound }}</template>
+ <option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option>
+ </FormSelect>
+ <FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
+ <template #label>{{ i18n.ts.volume }}</template>
+ </FormRange>
+
+ <div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton>
+ <FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormRange from '@/components/form/range.vue';
+import { i18n } from '@/i18n';
+import { playFile, soundsTypes } from '@/scripts/sound';
+
+const props = defineProps<{
+ type: string;
+ volume: number;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update', result: { type: string; volume: number; }): void;
+}>();
+
+let type = $ref(props.type);
+let volume = $ref(props.volume);
+
+function listen() {
+ playFile(type, volume);
+}
+
+function save() {
+ emit('update', { type, volume });
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
new file mode 100644
index 0000000000..ef60b2c3c9
--- /dev/null
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -0,0 +1,82 @@
+<template>
+<div class="_formRoot">
+ <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock">
+ <template #label>{{ i18n.ts.masterVolume }}</template>
+ </FormRange>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.sounds }}</template>
+ <FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;">
+ <template #label>{{ $t('_sfx.' + type) }}</template>
+ <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
+
+ <XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/>
+ </FormFolder>
+ </FormSection>
+
+ <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import XSound from './sounds.sound.vue';
+import FormRange from '@/components/form/range.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { playFile } from '@/scripts/sound';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const masterVolume = computed({
+ get: () => {
+ return ColdDeviceStorage.get('sound_masterVolume');
+ },
+ set: (value) => {
+ ColdDeviceStorage.set('sound_masterVolume', value);
+ },
+});
+
+const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
+
+const sounds = ref({
+ note: ColdDeviceStorage.get('sound_note'),
+ noteMy: ColdDeviceStorage.get('sound_noteMy'),
+ notification: ColdDeviceStorage.get('sound_notification'),
+ chat: ColdDeviceStorage.get('sound_chat'),
+ chatBg: ColdDeviceStorage.get('sound_chatBg'),
+ antenna: ColdDeviceStorage.get('sound_antenna'),
+ channel: ColdDeviceStorage.get('sound_channel'),
+});
+
+async function updated(type, sound) {
+ const v = {
+ type: sound.type,
+ volume: sound.volume,
+ };
+
+ ColdDeviceStorage.set('sound_' + type, v);
+ sounds.value[type] = v;
+}
+
+function reset() {
+ for (const sound of Object.keys(sounds.value)) {
+ const v = ColdDeviceStorage.default['sound_' + sound];
+ ColdDeviceStorage.set('sound_' + sound, v);
+ sounds.value[sound] = v;
+ }
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.sounds,
+ icon: 'ti ti-music',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
new file mode 100644
index 0000000000..608222386e
--- /dev/null
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -0,0 +1,140 @@
+<template>
+<div class="_formRoot">
+ <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock">
+ <template #label>{{ i18n.ts.type }}</template>
+ <option value="rss">RSS</option>
+ <option value="federation">Federation</option>
+ <option value="userList">User list timeline</option>
+ </FormSelect>
+
+ <MkInput v-model="statusbar.name" manual-save class="_formBlock">
+ <template #label>{{ i18n.ts.label }}</template>
+ </MkInput>
+
+ <MkSwitch v-model="statusbar.black" class="_formBlock">
+ <template #label>Black</template>
+ </MkSwitch>
+
+ <FormRadios v-model="statusbar.size" class="_formBlock">
+ <template #label>{{ i18n.ts.size }}</template>
+ <option value="verySmall">{{ i18n.ts.small }}+</option>
+ <option value="small">{{ i18n.ts.small }}</option>
+ <option value="medium">{{ i18n.ts.medium }}</option>
+ <option value="large">{{ i18n.ts.large }}</option>
+ <option value="veryLarge">{{ i18n.ts.large }}+</option>
+ </FormRadios>
+
+ <template v-if="statusbar.type === 'rss'">
+ <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkSwitch v-model="statusbar.props.shuffle" class="_formBlock">
+ <template #label>{{ i18n.ts.shuffle }}</template>
+ </MkSwitch>
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ </template>
+ <template v-else-if="statusbar.type === 'federation'">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ <MkSwitch v-model="statusbar.props.colored" class="_formBlock">
+ <template #label>{{ i18n.ts.colored }}</template>
+ </MkSwitch>
+ </template>
+ <template v-else-if="statusbar.type === 'userList' && userLists != null">
+ <FormSelect v-model="statusbar.props.userListId" class="_formBlock">
+ <template #label>{{ i18n.ts.userList }}</template>
+ <option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
+ </FormSelect>
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ </template>
+
+ <div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormRange from '@/components/form/range.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { deepClone } from '@/scripts/clone';
+
+const props = defineProps<{
+ _id: string;
+ userLists: any[] | null;
+}>();
+
+const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id)));
+
+watch(() => statusbar.type, () => {
+ if (statusbar.type === 'rss') {
+ statusbar.name = 'NEWS';
+ statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews';
+ statusbar.props.shuffle = true;
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ } else if (statusbar.type === 'federation') {
+ statusbar.name = 'FEDERATION';
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ statusbar.props.colored = false;
+ } else if (statusbar.type === 'userList') {
+ statusbar.name = 'LIST TL';
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ }
+});
+
+watch(statusbar, save);
+
+async function save() {
+ const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
+ const statusbars = deepClone(defaultStore.state.statusbars);
+ statusbars[i] = deepClone(statusbar);
+ defaultStore.set('statusbars', statusbars);
+}
+
+function del() {
+ defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
+}
+</script>
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
new file mode 100644
index 0000000000..86c69fa2c3
--- /dev/null
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="_formRoot">
+ <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock">
+ <template #label>{{ x.type ?? i18n.ts.notSet }}</template>
+ <template #suffix>{{ x.name }}</template>
+ <XStatusbar :_id="x.id" :user-lists="userLists"/>
+ </FormFolder>
+ <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XStatusbar from './statusbar.statusbar.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const statusbars = defaultStore.reactiveState.statusbars;
+
+let userLists = $ref();
+
+onMounted(() => {
+ os.api('users/lists/list').then(res => {
+ userLists = res;
+ });
+});
+
+async function add() {
+ defaultStore.push('statusbars', {
+ id: uuid(),
+ type: null,
+ black: false,
+ size: 'medium',
+ props: {},
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.statusbar,
+ icon: 'ti ti-list',
+ bg: 'var(--bg)',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
new file mode 100644
index 0000000000..52a436e18d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -0,0 +1,80 @@
+<template>
+<div class="_formRoot">
+ <FormTextarea v-model="installThemeCode" class="_formBlock">
+ <template #label>{{ i18n.ts._theme.code }}</template>
+ </FormTextarea>
+
+ <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton>
+ <FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormButton from '@/components/MkButton.vue';
+import { applyTheme, validateTheme } from '@/scripts/theme';
+import * as os from '@/os';
+import { addTheme, getThemes } from '@/theme-store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let installThemeCode = $ref(null);
+
+function parseThemeCode(code: string) {
+ let theme;
+
+ try {
+ theme = JSON5.parse(code);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: i18n.ts._theme.invalid,
+ });
+ return false;
+ }
+ if (!validateTheme(theme)) {
+ os.alert({
+ type: 'error',
+ text: i18n.ts._theme.invalid,
+ });
+ return false;
+ }
+ if (getThemes().some(t => t.id === theme.id)) {
+ os.alert({
+ type: 'info',
+ text: i18n.ts._theme.alreadyInstalled,
+ });
+ return false;
+ }
+
+ return theme;
+}
+
+function preview(code: string): void {
+ const theme = parseThemeCode(code);
+ if (theme) applyTheme(theme, false);
+}
+
+async function install(code: string): Promise<void> {
+ const theme = parseThemeCode(code);
+ if (!theme) return;
+ await addTheme(theme);
+ os.alert({
+ type: 'success',
+ text: i18n.t('_theme.installed', { name: theme.name }),
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._theme.install,
+ icon: 'ti ti-download',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
new file mode 100644
index 0000000000..409f0af650
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="_formRoot">
+ <FormSelect v-model="selectedThemeId" class="_formBlock">
+ <template #label>{{ i18n.ts.theme }}</template>
+ <optgroup :label="i18n.ts._theme.installedThemes">
+ <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="i18n.ts._theme.builtinThemes">
+ <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <template v-if="selectedTheme">
+ <FormInput readonly :model-value="selectedTheme.author" class="_formBlock">
+ <template #label>{{ i18n.ts.author }}</template>
+ </FormInput>
+ <FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock">
+ <template #label>{{ i18n.ts._theme.description }}</template>
+ </FormTextarea>
+ <FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock">
+ <template #label>{{ i18n.ts._theme.code }}</template>
+ <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template>
+ </FormTextarea>
+ <FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton>
+ </template>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+import { getThemes, removeTheme } from '@/theme-store';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const installedThemes = ref(getThemes());
+const builtinThemes = getBuiltinThemesRef();
+const selectedThemeId = ref(null);
+
+const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]);
+
+const selectedTheme = computed(() => {
+ if (selectedThemeId.value == null) return null;
+ return themes.value.find(x => x.id === selectedThemeId.value);
+});
+
+const selectedThemeCode = computed(() => {
+ if (selectedTheme.value == null) return null;
+ return JSON5.stringify(selectedTheme.value, null, '\t');
+});
+
+function copyThemeCode() {
+ copyToClipboard(selectedThemeCode.value);
+ os.success();
+}
+
+function uninstall() {
+ removeTheme(selectedTheme.value as Theme);
+ installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value);
+ selectedThemeId.value = null;
+ os.success();
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._theme.manage,
+ icon: 'ti ti-tool',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
new file mode 100644
index 0000000000..f37c213b06
--- /dev/null
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -0,0 +1,409 @@
+<template>
+<div class="_formRoot rsljpzjq">
+ <div v-adaptive-border class="rfqxtzch _panel _formBlock">
+ <div class="toggle">
+ <div class="toggleWrapper">
+ <input id="dn" v-model="darkMode" type="checkbox" class="dn"/>
+ <label for="dn" class="toggle">
+ <span class="before">{{ i18n.ts.light }}</span>
+ <span class="after">{{ i18n.ts.dark }}</span>
+ <span class="toggle__handler">
+ <span class="crater crater--1"></span>
+ <span class="crater crater--2"></span>
+ <span class="crater crater--3"></span>
+ </span>
+ <span class="star star--1"></span>
+ <span class="star star--2"></span>
+ <span class="star star--3"></span>
+ <span class="star star--4"></span>
+ <span class="star star--5"></span>
+ <span class="star star--6"></span>
+ </label>
+ </div>
+ </div>
+ <div class="sync">
+ <FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch>
+ </div>
+ </div>
+
+ <div class="selects _formBlock">
+ <FormSelect v-model="lightThemeId" large class="select">
+ <template #label>{{ i18n.ts.themeForLightMode }}</template>
+ <template #prefix><i class="ti ti-sun"></i></template>
+ <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option>
+ <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes">
+ <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="i18n.ts._theme.builtinThemes">
+ <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <FormSelect v-model="darkThemeId" large class="select">
+ <template #label>{{ i18n.ts.themeForDarkMode }}</template>
+ <template #prefix><i class="ti ti-moon"></i></template>
+ <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option>
+ <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes">
+ <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="i18n.ts._theme.builtinThemes">
+ <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ </div>
+
+ <FormSection>
+ <div class="_formLinksGrid">
+ <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink>
+ <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink>
+ <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink>
+ <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink>
+ </div>
+ </FormSection>
+
+ <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton>
+ <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onActivated, ref, watch } from 'vue';
+import JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/MkButton.vue';
+import { getBuiltinThemesRef } from '@/scripts/theme';
+import { selectFile } from '@/scripts/select-file';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { uniqueBy } from '@/scripts/array';
+import { fetchThemes, getThemes } from '@/theme-store';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const installedThemes = ref(getThemes());
+const builtinThemes = getBuiltinThemesRef();
+
+const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null);
+const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
+const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
+const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null);
+const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
+const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light'));
+const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id));
+
+const darkTheme = ColdDeviceStorage.ref('darkTheme');
+const darkThemeId = computed({
+ get() {
+ return darkTheme.value.id;
+ },
+ set(id) {
+ const t = themes.value.find(x => x.id === id);
+ if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+ ColdDeviceStorage.set('darkTheme', t);
+ }
+ },
+});
+const lightTheme = ColdDeviceStorage.ref('lightTheme');
+const lightThemeId = computed({
+ get() {
+ return lightTheme.value.id;
+ },
+ set(id) {
+ const t = themes.value.find(x => x.id === id);
+ if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+ ColdDeviceStorage.set('lightTheme', t);
+ }
+ },
+});
+const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
+const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
+const wallpaper = ref(localStorage.getItem('wallpaper'));
+const themesCount = installedThemes.value.length;
+
+watch(syncDeviceDarkMode, () => {
+ if (syncDeviceDarkMode.value) {
+ defaultStore.set('darkMode', isDeviceDarkmode());
+ }
+});
+
+watch(wallpaper, () => {
+ if (wallpaper.value == null) {
+ localStorage.removeItem('wallpaper');
+ } else {
+ localStorage.setItem('wallpaper', wallpaper.value);
+ }
+ location.reload();
+});
+
+onActivated(() => {
+ fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+ });
+});
+
+fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+});
+
+function setWallpaper(event) {
+ selectFile(event.currentTarget ?? event.target, null).then(file => {
+ wallpaper.value = file.url;
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.theme,
+ icon: 'ti ti-palette',
+});
+</script>
+
+<style lang="scss" scoped>
+.rfqxtzch {
+ border-radius: 6px;
+
+ > .toggle {
+ position: relative;
+ padding: 26px 0;
+ text-align: center;
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ > .toggleWrapper {
+ display: inline-block;
+ text-align: left;
+ overflow: clip;
+ padding: 0 100px;
+ vertical-align: bottom;
+
+ input {
+ position: absolute;
+ left: -99em;
+ }
+ }
+
+ .toggle {
+ cursor: pointer;
+ display: inline-block;
+ position: relative;
+ width: 90px;
+ height: 50px;
+ background-color: #83D8FF;
+ border-radius: 90px - 6;
+ transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+
+ > .before, > .after {
+ position: absolute;
+ top: 15px;
+ transition: color 1s ease;
+ }
+
+ > .before {
+ left: -70px;
+ color: var(--accent);
+ }
+
+ > .after {
+ right: -68px;
+ color: var(--fg);
+ }
+ }
+
+ .toggle__handler {
+ display: inline-block;
+ position: relative;
+ z-index: 1;
+ top: 3px;
+ left: 3px;
+ width: 50px - 6;
+ height: 50px - 6;
+ background-color: #FFCF96;
+ border-radius: 50px;
+ box-shadow: 0 2px 6px rgba(0,0,0,.3);
+ transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
+ transform: rotate(-45deg);
+
+ .crater {
+ position: absolute;
+ background-color: #E8CDA5;
+ opacity: 0;
+ transition: opacity 200ms ease-in-out !important;
+ border-radius: 100%;
+ }
+
+ .crater--1 {
+ top: 18px;
+ left: 10px;
+ width: 4px;
+ height: 4px;
+ }
+
+ .crater--2 {
+ top: 28px;
+ left: 22px;
+ width: 6px;
+ height: 6px;
+ }
+
+ .crater--3 {
+ top: 10px;
+ left: 25px;
+ width: 8px;
+ height: 8px;
+ }
+ }
+
+ .star {
+ position: absolute;
+ background-color: #ffffff;
+ transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ border-radius: 50%;
+ }
+
+ .star--1 {
+ top: 10px;
+ left: 35px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--2 {
+ top: 18px;
+ left: 28px;
+ z-index: 1;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--3 {
+ top: 27px;
+ left: 40px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 0;
+ transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--4 {
+ top: 16px;
+ left: 11px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--5 {
+ top: 32px;
+ left: 17px;
+ z-index: 0;
+ width: 3px;
+ height: 3px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--6 {
+ top: 36px;
+ left: 28px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ input:checked {
+ + .toggle {
+ background-color: #749DD6;
+
+ > .before {
+ color: var(--fg);
+ }
+
+ > .after {
+ color: var(--accent);
+ }
+
+ .toggle__handler {
+ background-color: #FFE5B5;
+ transform: translate3d(40px, 0, 0) rotate(0);
+
+ .crater { opacity: 1; }
+ }
+
+ .star--1 {
+ width: 2px;
+ height: 2px;
+ }
+
+ .star--2 {
+ width: 4px;
+ height: 4px;
+ transform: translate3d(-5px, 0, 0);
+ }
+
+ .star--3 {
+ width: 2px;
+ height: 2px;
+ transform: translate3d(-7px, 0, 0);
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 1;
+ transform: translate3d(0,0,0);
+ }
+
+ .star--4 {
+ transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--5 {
+ transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--6 {
+ transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+ }
+ }
+ }
+
+ > .sync {
+ padding: 14px 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+
+.rsljpzjq {
+ > .selects {
+ display: flex;
+ gap: 1.5em var(--margin);
+ flex-wrap: wrap;
+
+ > .select {
+ flex: 1;
+ min-width: 280px;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue
new file mode 100644
index 0000000000..c8ec1ea586
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.edit.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="_formRoot">
+ <FormInput v-model="name" class="_formBlock">
+ <template #label>Name</template>
+ </FormInput>
+
+ <FormInput v-model="url" type="url" class="_formBlock">
+ <template #label>URL</template>
+ </FormInput>
+
+ <FormInput v-model="secret" class="_formBlock">
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #label>Secret</template>
+ </FormInput>
+
+ <FormSection>
+ <template #label>Events</template>
+
+ <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
+ <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
+ <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
+ <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
+ <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
+ <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
+ <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
+ </FormSection>
+
+ <FormSwitch v-model="active" class="_formBlock">Active</FormSwitch>
+
+ <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormInput from '@/components/form/input.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+ webhookId: string;
+}>();
+
+const webhook = await os.api('i/webhooks/show', {
+ webhookId: props.webhookId,
+});
+
+let name = $ref(webhook.name);
+let url = $ref(webhook.url);
+let secret = $ref(webhook.secret);
+let active = $ref(webhook.active);
+
+let event_follow = $ref(webhook.on.includes('follow'));
+let event_followed = $ref(webhook.on.includes('followed'));
+let event_note = $ref(webhook.on.includes('note'));
+let event_reply = $ref(webhook.on.includes('reply'));
+let event_renote = $ref(webhook.on.includes('renote'));
+let event_reaction = $ref(webhook.on.includes('reaction'));
+let event_mention = $ref(webhook.on.includes('mention'));
+
+async function save(): Promise<void> {
+ const events = [];
+ if (event_follow) events.push('follow');
+ if (event_followed) events.push('followed');
+ if (event_note) events.push('note');
+ if (event_reply) events.push('reply');
+ if (event_renote) events.push('renote');
+ if (event_reaction) events.push('reaction');
+ if (event_mention) events.push('mention');
+
+ os.apiWithDialog('i/webhooks/update', {
+ name,
+ url,
+ secret,
+ webhookId: props.webhookId,
+ on: events,
+ active,
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Edit webhook',
+ icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue
new file mode 100644
index 0000000000..00a547da69
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.new.vue
@@ -0,0 +1,82 @@
+<template>
+<div class="_formRoot">
+ <FormInput v-model="name" class="_formBlock">
+ <template #label>Name</template>
+ </FormInput>
+
+ <FormInput v-model="url" type="url" class="_formBlock">
+ <template #label>URL</template>
+ </FormInput>
+
+ <FormInput v-model="secret" class="_formBlock">
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #label>Secret</template>
+ </FormInput>
+
+ <FormSection>
+ <template #label>Events</template>
+
+ <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch>
+ <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch>
+ <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch>
+ <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch>
+ <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch>
+ <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch>
+ <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch>
+ </FormSection>
+
+ <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormInput from '@/components/form/input.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let name = $ref('');
+let url = $ref('');
+let secret = $ref('');
+
+let event_follow = $ref(true);
+let event_followed = $ref(true);
+let event_note = $ref(true);
+let event_reply = $ref(true);
+let event_renote = $ref(true);
+let event_reaction = $ref(true);
+let event_mention = $ref(true);
+
+async function create(): Promise<void> {
+ const events = [];
+ if (event_follow) events.push('follow');
+ if (event_followed) events.push('followed');
+ if (event_note) events.push('note');
+ if (event_reply) events.push('reply');
+ if (event_renote) events.push('renote');
+ if (event_reaction) events.push('reaction');
+ if (event_mention) events.push('mention');
+
+ os.apiWithDialog('i/webhooks/create', {
+ name,
+ url,
+ secret,
+ on: events,
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Create new webhook',
+ icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue
new file mode 100644
index 0000000000..9be23ee4f0
--- /dev/null
+++ b/packages/frontend/src/pages/settings/webhook.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_formRoot">
+ <FormSection>
+ <FormLink :to="`/settings/webhook/new`">
+ Create webhook
+ </FormLink>
+ </FormSection>
+
+ <FormSection>
+ <MkPagination :pagination="pagination">
+ <template #default="{items}">
+ <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock">
+ <template #icon>
+ <i v-if="webhook.active === false" class="ti ti-player-pause"></i>
+ <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
+ <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
+ <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
+ </template>
+ {{ webhook.name || webhook.url }}
+ <template #suffix>
+ <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
+ </template>
+ </FormLink>
+ </template>
+ </MkPagination>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkPagination from '@/components/MkPagination.vue';
+import FormSection from '@/components/form/section.vue';
+import FormLink from '@/components/form/link.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagination = {
+ endpoint: 'i/webhooks/list' as const,
+ limit: 10,
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Webhook',
+ icon: 'ti ti-webhook',
+});
+</script>
diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue
new file mode 100644
index 0000000000..6961d8151d
--- /dev/null
+++ b/packages/frontend/src/pages/settings/word-mute.vue
@@ -0,0 +1,128 @@
+<template>
+<div class="_formRoot">
+ <MkTab v-model="tab" class="_formBlock">
+ <option value="soft">{{ i18n.ts._wordMute.soft }}</option>
+ <option value="hard">{{ i18n.ts._wordMute.hard }}</option>
+ </MkTab>
+ <div class="_formBlock">
+ <div v-show="tab === 'soft'">
+ <MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo>
+ <FormTextarea v-model="softMutedWords" class="_formBlock">
+ <span>{{ i18n.ts._wordMute.muteWords }}</span>
+ <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ </div>
+ <div v-show="tab === 'hard'">
+ <MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo>
+ <FormTextarea v-model="hardMutedWords" class="_formBlock">
+ <span>{{ i18n.ts._wordMute.muteWords }}</span>
+ <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ <MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock">
+ <template #key>{{ i18n.ts._wordMute.mutedNotes }}</template>
+ <template #value>{{ number(hardWordMutedNotesCount) }}</template>
+ </MkKeyValue>
+ </div>
+ </div>
+ <MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkTab from '@/components/MkTab.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { defaultStore } from '@/store';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const render = (mutedWords) => mutedWords.map(x => {
+ if (Array.isArray(x)) {
+ return x.join(' ');
+ } else {
+ return x;
+ }
+}).join('\n');
+
+const tab = ref('soft');
+const softMutedWords = ref(render(defaultStore.state.mutedWords));
+const hardMutedWords = ref(render($i!.mutedWords));
+const hardWordMutedNotesCount = ref(null);
+const changed = ref(false);
+
+os.api('i/get-word-muted-notes-count', {}).then(response => {
+ hardWordMutedNotesCount.value = response?.count;
+});
+
+watch(softMutedWords, () => {
+ changed.value = true;
+});
+
+watch(hardMutedWords, () => {
+ changed.value = true;
+});
+
+async function save() {
+ const parseMutes = (mutes, tab) => {
+ // split into lines, remove empty lines and unnecessary whitespace
+ let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
+
+ // check each line if it is a RegExp or not
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const regexp = line.match(/^\/(.+)\/(.*)$/);
+ if (regexp) {
+ // check that the RegExp is valid
+ try {
+ new RegExp(regexp[1], regexp[2]);
+ // note that regex lines will not be split by spaces!
+ } catch (err: any) {
+ // invalid syntax: do not save, do not reset changed flag
+ os.alert({
+ type: 'error',
+ title: i18n.ts.regexpError,
+ text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
+ });
+ // re-throw error so these invalid settings are not saved
+ throw err;
+ }
+ } else {
+ lines[i] = line.split(' ');
+ }
+ }
+
+ return lines;
+ };
+
+ let softMutes, hardMutes;
+ try {
+ softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft);
+ hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard);
+ } catch (err) {
+ // already displayed error message in parseMutes
+ return;
+ }
+
+ defaultStore.set('mutedWords', softMutes);
+ await os.api('i/update', {
+ mutedWords: hardMutes,
+ });
+
+ changed.value = false;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.wordMute,
+ icon: 'ti ti-message-off',
+});
+</script>