summaryrefslogtreecommitdiff
path: root/src/client/pages/settings
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-10-17 20:12:00 +0900
committerGitHub <noreply@github.com>2020-10-17 20:12:00 +0900
commit7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a (patch)
tree2263a06acec7fa21882366bae26d1a983ce21135 /src/client/pages/settings
parentCW の input でも投稿ショートカットが動作するように (#6690) (diff)
downloadmisskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.gz
misskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.bz2
misskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.zip
Migrate to Vue3 (#6587)
* Update reaction.vue * fix bug * wip * wip * wjio * wip * Revert "wip" This reverts commit e427f2160adf4e8a4147006e25a89854edab0033. * wip * wip * wip * Update init.ts * Update drive-window.vue * wip * wip * Use PascalCase for components * Use PascalCase for components * update dep * wip * wip * wip * Update init.ts * wip * Update paging.ts * Update test.vue * watch deep * wip * lint * wip * wip * wip * wip * wiop * wip * Update webpack.config.ts * alllow null poll * wip * wip * wip * wiop * UI redesign & refactor (#6714) * wip * wip * wip * wip * wip * Update drive.vue * Update word-mute.vue * wip * wip * wip * clean up * wip * Update default.vue * wip * Update notes.vue * Update mfm.ts * Update index.home.vue * Update post-form.vue * Update post-form-attaches.vue * wip * Update post-form.vue * Update sidebar.vue * wip * wip * Update index.vue * wip * Update default.vue * Update index.vue * Update index.vue * wip * Update post-form-attaches.vue * Update note.vue * wip * clean up * Update notes.vue * wip * wip * Update ja-JP.yml * wip * wip * Update index.vue * wip * wip * wip * wip * wip * wip * wip * wip * Update default.vue * wip * Update _dark.json5 * wip * wip * wip * clean up * wip * wip * Update index.vue * Update test.vue * wip * wip * fix * wip * wip * wip * wip * clena yop * wip * wip * Update store.ts * Update messaging-room.vue * Update default.widgets.vue * fix * wip * wip * Update modal.vue * wip * Update os.ts * Update os.ts * Update deck.vue * Update init.ts * wip * Update ja-JP.yml * v-sizeは単にwindowのresizeを監視するだけで良いかもしれない * Update modal.vue * wip * Update tooltip.ts * wip * wip * wip * wip * wip * Update image-viewer.vue * wip * wip * Update style.scss * Update style.scss * Update visitor.vue * wip * Update init.ts * Update init.ts * wip * wip * Update visitor.vue * Update visitor.vue * Update visitor.vue * Update visitor.vue * wip * wip * Update modal.vue * Update header.vue * Update menu.vue * Update about.vue * Update about-misskey.vue * wip * wip * Update visitor.vue * Update tooltip.ts * wip * Update drive.vue * wip * Update style.scss * Update header.vue * wip * wip * Update users.user.vue * Update announcements.vue * wip * wip * wip * Update emojis.vue * wip * Update emojis.vue * Update style.scss * Update users.vue * wip * Update style.scss * wip * Update welcome.entrance.vue * Update radio.vue * Update size.ts * Update emoji-edit-dialog.vue * wip * Update emojis.vue * wip * Update emojis.vue * Update emojis.vue * Update emojis.vue * wip * wip * wip * wip * Update file-dialog.vue * wip * wip * Update token-generate-window.vue * Update notification-setting-window.vue * wip * wip * Update _error_.vue * Update ja-JP.yml * wip * wip * Update store.ts * Update emojis.vue * Update emojis.vue * Update emojis.vue * Update announcements.vue * Update store.ts * wip * Update page-editor.vue * wip * wip * Update modal.vue * wip * Update select-file.ts * Update timeline.vue * Update emojis.vue * Update os.ts * wip * Update user-select.vue * Update mfm.ts * Update get-file-info.ts * Update drive.vue * Update init.ts * Update mfm.ts * wip * wip * Update window.vue * Update note.vue * wip * wip * Update user-info.vue * wip * wip * wip * wip * wip * Update header.vue * Update header.vue * wip * Update explore.vue * wip * wip * wip * Update webpack.config.ts * wip * wip * wip * wip * wip * wip * Update autocomplete.ts * wip * wip * wip * Update toast.vue * wip * Update post-form-dialog.vue * wip * wip * wip * wip * wip * Update users.vue * wip * Update explore.vue * wip * wip * wip * Update package.json * wip * Update icon-dialog.vue * wip * wip * Update user-preview.ts * wip * wip * wip * wip * wip * Update instance.vue * Update user-name.vue * Update federation.vue * Update instance.vue * wip * wip * Update tag.vue * wip * wip * wip * wip * wip * Update instance.vue * wip * Update os.ts * Update os.ts * wip * wip * wip * Update router.ts * wip * Update init.ts * Update note.vue * Update messages.vue * wip * wip * wip * wip * wip * google * wip * wip * wip * wip * Update theme-editor.vue * wip * wip * Update room.vue * Update channel-editor.vue * wip * Update window.vue * Update window.vue * wip * Update window.vue * Update window.vue * wip * Update menu.vue * wip * wip * wip * wip * Update messaging-room.vue * wip * Update post-form.vue * Update default.widgets.vue * Update window.vue * wip
Diffstat (limited to 'src/client/pages/settings')
-rw-r--r--src/client/pages/settings/api.vue59
-rw-r--r--src/client/pages/settings/drive.vue60
-rw-r--r--src/client/pages/settings/general.vue219
-rw-r--r--src/client/pages/settings/import-export.vue119
-rw-r--r--src/client/pages/settings/index.vue154
-rw-r--r--src/client/pages/settings/integration.vue136
-rw-r--r--src/client/pages/settings/mute-block.vue93
-rw-r--r--src/client/pages/settings/notifications.vue93
-rw-r--r--src/client/pages/settings/other.vue51
-rw-r--r--src/client/pages/settings/plugins.vue200
-rw-r--r--src/client/pages/settings/privacy.vue86
-rw-r--r--src/client/pages/settings/profile.vue232
-rw-r--r--src/client/pages/settings/reaction.vue95
-rw-r--r--src/client/pages/settings/security.2fa.vue235
-rw-r--r--src/client/pages/settings/security.vue102
-rw-r--r--src/client/pages/settings/sidebar.vue110
-rw-r--r--src/client/pages/settings/sounds.vue152
-rw-r--r--src/client/pages/settings/theme.vue499
-rw-r--r--src/client/pages/settings/word-mute.vue101
19 files changed, 2796 insertions, 0 deletions
diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue
new file mode 100644
index 0000000000..326ba90062
--- /dev/null
+++ b/src/client/pages/settings/api.vue
@@ -0,0 +1,59 @@
+<template>
+<section class="_section">
+ <div class="_content">
+ <MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faKey } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/ui/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInput
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: 'API',
+ icon: faKey
+ }]
+ },
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ async generateToken() {
+ os.popup(await import('@/components/token-generate-window.vue'), {}, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ os.dialog({
+ type: 'success',
+ title: this.$t('token'),
+ text: token
+ });
+ },
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue
new file mode 100644
index 0000000000..a7d623be37
--- /dev/null
+++ b/src/client/pages/settings/drive.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="uawsfosz _section">
+ <div class="_title"><Fa :icon="faCloud"/> {{ $t('drive') }}</div>
+ <div class="_content">
+ <span>{{ $t('uploadFolder') }}: {{ uploadFolder ? uploadFolder.name : '-' }}</span>
+ <MkButton primary @click="chooseUploadFolder()"><Fa :icon="faFolderOpen"/> {{ $t('selectFolder') }}</MkButton>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCloud, faFolderOpen } from '@fortawesome/free-solid-svg-icons';
+import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ uploadFolder: null,
+ faCloud, faClock, faEyeSlash, faFolderOpen, faTrashAlt
+ }
+ },
+
+ async created() {
+ if (this.$store.state.settings.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.settings.uploadFolder
+ });
+ }
+ },
+
+ methods: {
+ chooseUploadFolder() {
+ os.selectDriveFolder(false).then(async folder => {
+ await this.$store.dispatch('settings/set', { key: 'uploadFolder', value: folder ? folder.id : null });
+ os.success();
+ if (this.$store.state.settings.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.settings.uploadFolder
+ });
+ } else {
+ this.uploadFolder = null;
+ }
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uawsfosz {
+
+}
+</style>
diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue
new file mode 100644
index 0000000000..80152c5e6a
--- /dev/null
+++ b/src/client/pages/settings/general.vue
@@ -0,0 +1,219 @@
+<template>
+<div class="_section">
+ <section class="_card _vMargin">
+ <div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div>
+ <div class="_content">
+ <div>{{ $t('whenServerDisconnected') }}</div>
+ <MkRadio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</MkRadio>
+ <MkRadio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</MkRadio>
+ <MkRadio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</MkRadio>
+ </div>
+ <div class="_content">
+ <MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch>
+ <MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch>
+ <MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch>
+ <MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch>
+ </div>
+ <div class="_content">
+ <div>{{ $t('chatOpenBehavior') }}</div>
+ <MkRadio v-model="chatOpenBehavior" value="page">{{ $t('showInPage') }}</MkRadio>
+ <MkRadio v-model="chatOpenBehavior" value="window">{{ $t('openInWindow') }}</MkRadio>
+ <MkRadio v-model="chatOpenBehavior" value="popout">{{ $t('popout') }}</MkRadio>
+ </div>
+ <div class="_content">
+ <MkSelect v-model:value="lang">
+ <template #label>{{ $t('uiLanguage') }}</template>
+
+ <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+ </MkSelect>
+ </div>
+ </section>
+
+ <section class="_card _vMargin">
+ <div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div>
+ <div class="_content">
+ <MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch>
+ <MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch>
+ <MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch>
+ <MkSwitch v-model:value="useOsNativeEmojis">
+ {{ $t('useOsNativeEmojis') }}
+ <template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template>
+ </MkSwitch>
+ </div>
+ <div class="_content">
+ <div>{{ $t('fontSize') }}</div>
+ <MkRadio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></MkRadio>
+ <MkRadio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></MkRadio>
+ <MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio>
+ <MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio>
+ </div>
+ </section>
+
+ <section class="_card _vMargin">
+ <div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div>
+ <div class="_content">
+ <MkSwitch v-model:value="deckAlwaysShowMainColumn">
+ {{ $t('_deck.alwaysShowMainColumn') }}
+ </MkSwitch>
+ </div>
+ <div class="_content">
+ <div>{{ $t('_deck.columnAlign') }}</div>
+ <MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio>
+ <MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio>
+ </div>
+ </section>
+
+ <MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkRadio from '@/components/ui/radio.vue';
+import MkRange from '@/components/ui/range.vue';
+import { langs } from '@/config';
+import { clientDb, set } from '@/db';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ MkSelect,
+ MkRadio,
+ MkRange,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('general'),
+ icon: faCogs
+ }]
+ },
+ langs,
+ lang: localStorage.getItem('lang'),
+ fontSize: localStorage.getItem('fontSize'),
+ faImage, faCog, faColumns
+ }
+ },
+
+ computed: {
+ serverDisconnectedBehavior: {
+ get() { return this.$store.state.device.serverDisconnectedBehavior; },
+ set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); }
+ },
+
+ reduceAnimation: {
+ get() { return !this.$store.state.device.animation; },
+ set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); }
+ },
+
+ useBlurEffectForModal: {
+ get() { return this.$store.state.device.useBlurEffectForModal; },
+ set(value) { this.$store.commit('device/set', { key: 'useBlurEffectForModal', value: value }); }
+ },
+
+ disableAnimatedMfm: {
+ get() { return !this.$store.state.device.animatedMfm; },
+ set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); }
+ },
+
+ useOsNativeEmojis: {
+ get() { return this.$store.state.device.useOsNativeEmojis; },
+ set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); }
+ },
+
+ imageNewTab: {
+ get() { return this.$store.state.device.imageNewTab; },
+ set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); }
+ },
+
+ disablePagesScript: {
+ get() { return this.$store.state.device.disablePagesScript; },
+ set(value) { this.$store.commit('device/set', { key: 'disablePagesScript', value }); }
+ },
+
+ showFixedPostForm: {
+ get() { return this.$store.state.device.showFixedPostForm; },
+ set(value) { this.$store.commit('device/set', { key: 'showFixedPostForm', value }); }
+ },
+
+ chatOpenBehavior: {
+ get() { return this.$store.state.device.chatOpenBehavior; },
+ set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); }
+ },
+
+ enableInfiniteScroll: {
+ get() { return this.$store.state.device.enableInfiniteScroll; },
+ set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); }
+ },
+
+ deckAlwaysShowMainColumn: {
+ get() { return this.$store.state.device.deckAlwaysShowMainColumn; },
+ set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); }
+ },
+
+ deckColumnAlign: {
+ get() { return this.$store.state.device.deckColumnAlign; },
+ set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); }
+ },
+ },
+
+ watch: {
+ lang() {
+ localStorage.setItem('lang', this.lang);
+
+ return set('_version_', `changeLang-${(new Date()).toJSON()}`, clientDb.i18n)
+ .then(() => location.reload())
+ .catch(() => {
+ os.dialog({
+ type: 'error',
+ });
+ });
+ },
+
+ fontSize() {
+ if (this.fontSize == null) {
+ localStorage.removeItem('fontSize');
+ } else {
+ localStorage.setItem('fontSize', this.fontSize);
+ }
+ location.reload();
+ },
+
+ enableInfiniteScroll() {
+ location.reload()
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ cacheClear() {
+ // Clear cache (service worker)
+ try {
+ navigator.serviceWorker.controller.postMessage('clear');
+
+ navigator.serviceWorker.getRegistrations().then(registrations => {
+ for (const registration of registrations) registration.unregister();
+ });
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Force reload
+ location.reload(true);
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue
new file mode 100644
index 0000000000..a5a0085277
--- /dev/null
+++ b/src/client/pages/settings/import-export.vue
@@ -0,0 +1,119 @@
+<template>
+<section class="_section">
+ <div class="_title"><Fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
+ <div class="_content">
+ <MkSelect v-model:value="exportTarget">
+ <option value="notes">{{ $t('_exportOrImport.allNotes') }}</option>
+ <option value="following">{{ $t('_exportOrImport.followingList') }}</option>
+ <option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option>
+ <option value="mute">{{ $t('_exportOrImport.muteList') }}</option>
+ <option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option>
+ </MkSelect>
+ <MkButton inline @click="doExport()"><Fa :icon="faDownload"/> {{ $t('export') }}</MkButton>
+ <MkButton inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><Fa :icon="faUpload"/> {{ $t('import') }}</MkButton>
+ </div>
+ <input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/ui/select.vue';
+import { apiUrl } from '@/config';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ },
+
+ data() {
+ return {
+ exportTarget: 'notes',
+ faDownload, faUpload, faBoxes
+ }
+ },
+
+ methods: {
+ doExport() {
+ os.api(
+ this.exportTarget == 'notes' ? 'i/export-notes' :
+ this.exportTarget == 'following' ? 'i/export-following' :
+ this.exportTarget == 'blocking' ? 'i/export-blocking' :
+ this.exportTarget == 'user-lists' ? 'i/export-user-lists' :
+ null, {})
+ .then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$t('exportRequested')
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ doImport() {
+ (this.$refs.file as any).click();
+ },
+
+ onChangeFile() {
+ const [file] = Array.from((this.$refs.file as any).files);
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ const dialog = os.dialog({
+ type: 'waiting',
+ text: this.$t('uploading') + '...',
+ showOkButton: false,
+ showCancelButton: false,
+ cancelableByBgClick: false
+ });
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.reqImport(f);
+ })
+ .catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ })
+ .finally(() => {
+ dialog.close();
+ });
+ },
+
+ reqImport(file) {
+ os.api(
+ this.exportTarget == 'following' ? 'i/import-following' :
+ this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
+ null, {
+ fileId: file.id
+ }).then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$t('importRequested')
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
new file mode 100644
index 0000000000..4ca30ee686
--- /dev/null
+++ b/src/client/pages/settings/index.vue
@@ -0,0 +1,154 @@
+<template>
+<div class="vvcocwet" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || $route.name === 'settings'">
+ <div class="menu">
+ <div class="label">{{ $t('basicSettings') }}</div>
+ <router-link class="item" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</router-link>
+ <router-link class="item" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</router-link>
+ <router-link class="item" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</router-link>
+ <router-link class="item" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</router-link>
+ <router-link class="item" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</router-link>
+ <router-link class="item" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</router-link>
+ </div>
+ <div class="menu">
+ <div class="label">{{ $t('clientSettings') }}</div>
+ <router-link class="item" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</router-link>
+ <router-link class="item" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</router-link>
+ <router-link class="item" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</router-link>
+ <router-link class="item" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</router-link>
+ <router-link class="item" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</router-link>
+ </div>
+ <div class="menu">
+ <div class="label">{{ $t('otherSettings') }}</div>
+ <router-link class="item" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</router-link>
+ <router-link class="item" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</router-link>
+ <router-link class="item" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</router-link>
+ <router-link class="item" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</router-link>
+ </div>
+ <div class="menu">
+ <button class="_button item" @click="logout">{{ $t('logout') }}</button>
+ </div>
+ </div>
+ <div class="main">
+ <router-view v-slot="{ Component }">
+ <transition :name="($store.state.device.animation && !narrow) ? 'view-slide' : ''" appear mode="out-in">
+ <component :is="Component" @info="onInfo"/>
+ </transition>
+ </router-view>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref } from 'vue';
+import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey } from '@fortawesome/free-solid-svg-icons';
+import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons';
+import { store } from '@/store';
+import { i18n } from '@/i18n';
+
+export default defineComponent({
+ setup(props, context) {
+ const INFO = ref({
+ header: [{
+ title: i18n.global.t('settings'),
+ icon: faCog
+ }]
+ });
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const onInfo = (viewInfo) => {
+ INFO.value = viewInfo;
+ };
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 650;
+ });
+
+ return {
+ INFO,
+ narrow,
+ view,
+ el,
+ onInfo,
+ logout: () => {
+ store.dispatch('logout');
+ location.href = '/';
+ },
+ faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.view-slide-enter-active, .view-slide-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.view-slide-enter-from, .view-slide-leave-to {
+ opacity: 0;
+ transform: translateX(32px);
+}
+
+.vvcocwet {
+ max-width: 1000px;
+ margin: 0 auto;
+
+ > .nav {
+ > .menu {
+ margin: 16px 0;
+
+ > .label {
+ padding: 8px 32px;
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .item {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0 32px;
+ line-height: 48px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ //background: var(--panel);
+ //border-bottom: solid 1px var(--divider);
+ transition: padding 0.2s ease, color 0.1s ease;
+
+ &:first-of-type {
+ //border-top: solid 1px var(--divider);
+ }
+
+ &.router-link-active {
+ color: var(--accent);
+ padding-left: 42px;
+ }
+
+ &:hover {
+ text-decoration: none;
+ padding-left: 42px;
+ }
+
+ > .icon {
+ margin-right: 0.5em;
+ }
+ }
+ }
+ }
+
+ &.wide {
+ display: flex;
+
+ > .nav {
+ width: 30%;
+ max-width: 260px;
+ }
+
+ > .main {
+ flex: 1;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue
new file mode 100644
index 0000000000..4f07417160
--- /dev/null
+++ b/src/client/pages/settings/integration.vue
@@ -0,0 +1,136 @@
+<template>
+<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
+ <div class="_content" v-if="enableTwitterIntegration">
+ <header><Fa :icon="faTwitter"/> Twitter</header>
+ <p v-if="integrations.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
+ <MkButton v-if="integrations.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</MkButton>
+ <MkButton v-else @click="connectTwitter">{{ $t('connectSerice') }}</MkButton>
+ </div>
+
+ <div class="_content" v-if="enableDiscordIntegration">
+ <header><Fa :icon="faDiscord"/> Discord</header>
+ <p v-if="integrations.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
+ <MkButton v-if="integrations.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</MkButton>
+ <MkButton v-else @click="connectDiscord">{{ $t('connectSerice') }}</MkButton>
+ </div>
+
+ <div class="_content" v-if="enableGithubIntegration">
+ <header><Fa :icon="faGithub"/> GitHub</header>
+ <p v-if="integrations.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
+ <MkButton v-if="integrations.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</MkButton>
+ <MkButton v-else @click="connectGithub">{{ $t('connectSerice') }}</MkButton>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
+import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
+import { apiUrl } from '@/config';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('integration'),
+ icon: faShareAlt
+ }]
+ },
+ apiUrl,
+ twitterForm: null,
+ discordForm: null,
+ githubForm: null,
+ enableTwitterIntegration: false,
+ enableDiscordIntegration: false,
+ enableGithubIntegration: false,
+ faShareAlt, faTwitter, faDiscord, faGithub
+ };
+ },
+
+ computed: {
+ integrations() {
+ return this.$store.state.i.integrations;
+ },
+
+ meta() {
+ return this.$store.state.instance.meta;
+ },
+ },
+
+ created() {
+ this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
+ this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
+ this.enableGithubIntegration = this.meta.enableGithubIntegration;
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+
+ document.cookie = `igi=${this.$store.state.i.token}; path=/;` +
+ ` max-age=31536000;` +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+
+ this.$watch('integrations', () => {
+ if (this.integrations.twitter) {
+ if (this.twitterForm) this.twitterForm.close();
+ }
+ if (this.integrations.discord) {
+ if (this.discordForm) this.discordForm.close();
+ }
+ if (this.integrations.github) {
+ if (this.githubForm) this.githubForm.close();
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ connectTwitter() {
+ this.twitterForm = window.open(apiUrl + '/connect/twitter',
+ 'twitter_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectTwitter() {
+ window.open(apiUrl + '/disconnect/twitter',
+ 'twitter_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectDiscord() {
+ this.discordForm = window.open(apiUrl + '/connect/discord',
+ 'discord_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectDiscord() {
+ window.open(apiUrl + '/disconnect/discord',
+ 'discord_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectGithub() {
+ this.githubForm = window.open(apiUrl + '/connect/github',
+ 'github_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectGithub() {
+ window.open(apiUrl + '/disconnect/github',
+ 'github_disconnect_window',
+ 'height=570, width=520');
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..5a08a8caae
--- /dev/null
+++ b/src/client/pages/settings/mute-block.vue
@@ -0,0 +1,93 @@
+<template>
+<section class="rrfwjxfl _section">
+ <MkTab v-model:value="tab" :items="[{ label: $t('mutedUsers'), value: 'mute' }, { label: $t('blockedUsers'), value: 'block' }]" style="margin-bottom: var(--margin);"/>
+ <div class="_content" v-if="tab === 'mute'">
+ <MkPagination :pagination="mutingPagination" class="muting">
+ <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template>
+ <template #default="{items}">
+ <div class="user" v-for="mute in items" :key="mute.id">
+ <router-link class="name" :to="userPage(mute.mutee)">
+ <MkAcct :user="mute.mutee"/>
+ </router-link>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+ <div class="_content" v-if="tab === 'block'">
+ <MkPagination :pagination="blockingPagination" class="blocking">
+ <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template>
+ <template #default="{items}">
+ <div class="user" v-for="block in items" :key="block.id">
+ <router-link class="name" :to="userPage(block.blockee)">
+ <MkAcct :user="block.blockee"/>
+ </router-link>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faBan } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkTab,
+ MkInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('muteAndBlock'),
+ icon: faBan
+ }]
+ },
+ tab: 'mute',
+ mutingPagination: {
+ endpoint: 'mute/list',
+ limit: 10,
+ },
+ blockingPagination: {
+ endpoint: 'blocking/list',
+ limit: 10,
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rrfwjxfl {
+ > ._content {
+ max-height: 350px;
+ overflow: auto;
+
+ > .muting,
+ > .blocking {
+ > .empty {
+ opacity: 0.5 !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue
new file mode 100644
index 0000000000..98dc85ea52
--- /dev/null
+++ b/src/client/pages/settings/notifications.vue
@@ -0,0 +1,93 @@
+<template>
+<div>
+ <div class="_section">
+ <MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton>
+ </div>
+ <div class="_section">
+ <div class="_card">
+ <div class="_content">
+ <MkSwitch v-model:value="$store.state.i.autoWatch" @update:value="onChangeAutoWatch">
+ {{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template>
+ </MkSwitch>
+ </div>
+ </div>
+ </div>
+ <div class="_section">
+ <MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton>
+ <MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton>
+ <MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import { notificationTypes } from '../../../types';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('notifications'),
+ icon: faBell
+ }]
+ },
+ faCog
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ onChangeAutoWatch(v) {
+ os.api('i/update', {
+ autoWatch: v
+ });
+ },
+
+ readAllUnreadNotes() {
+ os.api('i/read-all-unread-notes');
+ },
+
+ readAllMessagingMessages() {
+ os.api('i/read-all-messaging-messages');
+ },
+
+ readAllNotifications() {
+ os.api('notifications/mark-all-as-read');
+ },
+
+ async configure() {
+ const includingTypes = notificationTypes.filter(x => !this.$store.state.i.mutingNotificationTypes.includes(x));
+ os.popup(await import('@/components/notification-setting-window.vue'), {
+ includingTypes,
+ showGlobalToggle: false,
+ }, {
+ done: async (res) => {
+ const { includingTypes: value } = res;
+ await os.apiWithDialog('i/update', {
+ mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
+ }).then(i => {
+ this.$store.state.i.mutingNotificationTypes = i.mutingNotificationTypes;
+ });
+ }
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue
new file mode 100644
index 0000000000..ebc5644162
--- /dev/null
+++ b/src/client/pages/settings/other.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <div class="_content">
+ <MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote">
+ {{ $t('showFeaturedNotesInTimeline') }}
+ </MkSwitch>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faEllipsisH } from '@fortawesome/free-solid-svg-icons';
+import MkSelect from '@/components/ui/select.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkSelect,
+ MkSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('other'),
+ icon: faEllipsisH
+ }]
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ onChangeInjectFeaturedNote(v) {
+ os.api('i/update', {
+ injectFeaturedNote: v
+ });
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/plugins.vue b/src/client/pages/settings/plugins.vue
new file mode 100644
index 0000000000..246624ddd4
--- /dev/null
+++ b/src/client/pages/settings/plugins.vue
@@ -0,0 +1,200 @@
+<template>
+<section class="_section">
+ <div class="_title"><Fa :icon="faPlug"/> {{ $t('plugins') }}</div>
+ <div class="_content">
+ <details>
+ <summary><Fa :icon="faDownload"/> {{ $t('install') }}</summary>
+ <MkInfo warn>{{ $t('pluginInstallWarn') }}</MkInfo>
+ <MkTextarea v-model:value="script" tall>
+ <span>{{ $t('script') }}</span>
+ </MkTextarea>
+ <MkButton @click="install()" primary><Fa :icon="faSave"/> {{ $t('install') }}</MkButton>
+ </details>
+ </div>
+ <div class="_content">
+ <details>
+ <summary><Fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary>
+ <MkSelect v-model:value="selectedPluginId">
+ <option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </MkSelect>
+ <template v-if="selectedPlugin">
+ <div style="margin: -8px 0 8px 0;">
+ <MkSwitch :value="selectedPlugin.active" @update:value="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</MkSwitch>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $t('version') }}:</div>
+ <div>{{ selectedPlugin.version }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $t('author') }}:</div>
+ <div>{{ selectedPlugin.author }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $t('description') }}:</div>
+ <div>{{ selectedPlugin.description }}</div>
+ </div>
+ <div style="margin-top: 8px;">
+ <MkButton @click="config()" inline v-if="selectedPlugin.config"><Fa :icon="faCog"/> {{ $t('settings') }}</MkButton>
+ <MkButton @click="uninstall()" inline><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton>
+ </div>
+ </template>
+ </details>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { AiScript, parse } from '@syuilo/aiscript';
+import { serialize } from '@syuilo/aiscript/built/serializer';
+import { v4 as uuid } from 'uuid';
+import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkTextarea,
+ MkSelect,
+ MkInfo,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ script: '',
+ selectedPluginId: null,
+ faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog
+ }
+ },
+
+ computed: {
+ selectedPlugin() {
+ if (this.selectedPluginId == null) return null;
+ return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId);
+ },
+ },
+
+ methods: {
+ async install() {
+ let ast;
+ try {
+ ast = parse(this.script);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ const meta = AiScript.collectMetadata(ast);
+ if (meta == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const data = meta.get(null);
+ if (data == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const { name, version, author, description, permissions, config } = data;
+ if (name == null || version == null || author == null) {
+ os.dialog({
+ type: 'error',
+ text: 'Required property not found :('
+ });
+ return;
+ }
+
+ const token = permissions == null || permissions.length === 0 ? null : await new Promise(async (res, rej) => {
+ os.popup(await import('@/components/token-generate-window.vue'), {
+ title: this.$t('tokenRequested'),
+ information: this.$t('pluginTokenRequestedDescription'),
+ initialName: name,
+ initialPermissions: permissions
+ }, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ res(token);
+ }
+ }, 'closed');
+ });
+
+ this.$store.commit('deviceUser/installPlugin', {
+ id: uuid(),
+ meta: {
+ name, version, author, description, permissions, config
+ },
+ token,
+ ast: serialize(ast)
+ });
+
+ os.success();
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ },
+
+ uninstall() {
+ this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId);
+ os.success();
+ this.$nextTick(() => {
+ location.reload();
+ });
+ },
+
+ // TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
+ async config() {
+ const config = this.selectedPlugin.config;
+ for (const key in this.selectedPlugin.configData) {
+ config[key].default = this.selectedPlugin.configData[key];
+ }
+
+ const { canceled, result } = await os.form(this.selectedPlugin.name, config);
+ if (canceled) return;
+
+ this.$store.commit('deviceUser/configPlugin', {
+ id: this.selectedPluginId,
+ config: result
+ });
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ },
+
+ changeActive(plugin, active) {
+ this.$store.commit('deviceUser/changePluginActive', {
+ id: plugin.id,
+ active: active
+ });
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue
new file mode 100644
index 0000000000..a92baca9d9
--- /dev/null
+++ b/src/client/pages/settings/privacy.vue
@@ -0,0 +1,86 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <div class="_content">
+ <MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch>
+ <MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch>
+ </div>
+ <div class="_content">
+ <MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch>
+ <MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility">
+ <template #label>{{ $t('defaultNoteVisibility') }}</template>
+ <option value="public">{{ $t('_visibility.public') }}</option>
+ <option value="home">{{ $t('_visibility.home') }}</option>
+ <option value="followers">{{ $t('_visibility.followers') }}</option>
+ <option value="specified">{{ $t('_visibility.specified') }}</option>
+ </MkSelect>
+ <MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faLockOpen } from '@fortawesome/free-solid-svg-icons';
+import MkSelect from '@/components/ui/select.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkSelect,
+ MkSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('privacy'),
+ icon: faLockOpen
+ }]
+ },
+ isLocked: false,
+ autoAcceptFollowed: false,
+ }
+ },
+
+ computed: {
+ defaultNoteVisibility: {
+ get() { return this.$store.state.settings.defaultNoteVisibility; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
+ },
+
+ defaultNoteLocalOnly: {
+ get() { return this.$store.state.settings.defaultNoteLocalOnly; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteLocalOnly', value }); }
+ },
+
+ rememberNoteVisibility: {
+ get() { return this.$store.state.settings.rememberNoteVisibility; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
+ },
+ },
+
+ created() {
+ this.isLocked = this.$store.state.i.isLocked;
+ this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ save() {
+ os.api('i/update', {
+ isLocked: !!this.isLocked,
+ autoAcceptFollowed: !!this.autoAcceptFollowed,
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue
new file mode 100644
index 0000000000..4444b4f484
--- /dev/null
+++ b/src/client/pages/settings/profile.vue
@@ -0,0 +1,232 @@
+<template>
+<div class="_section">
+ <div class="llvierxe _card">
+ <div class="_title"><Fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div>
+ <div class="_content">
+ <div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner">
+ <MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/>
+ </div>
+
+ <MkInput v-model:value="name" :max="30">
+ <span>{{ $t('_profile.name') }}</span>
+ </MkInput>
+
+ <MkTextarea v-model:value="description" :max="500">
+ <span>{{ $t('_profile.description') }}</span>
+ <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
+ </MkTextarea>
+
+ <MkInput v-model:value="location">
+ <span>{{ $t('location') }}</span>
+ <template #prefix><Fa :icon="faMapMarkerAlt"/></template>
+ </MkInput>
+
+ <MkInput v-model:value="birthday" type="date">
+ <template #title>{{ $t('birthday') }}</template>
+ <template #prefix><Fa :icon="faBirthdayCake"/></template>
+ </MkInput>
+
+ <details class="fields">
+ <summary>{{ $t('_profile.metadata') }}</summary>
+ <div class="row">
+ <MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput>
+ <MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput>
+ </div>
+ <div class="row">
+ <MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput>
+ <MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput>
+ </div>
+ <div class="row">
+ <MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput>
+ <MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput>
+ </div>
+ <div class="row">
+ <MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput>
+ <MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput>
+ </div>
+ </details>
+
+ <MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch>
+ <MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch>
+ </div>
+ <div class="_footer">
+ <MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons';
+import { faSave } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/ui/input.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import { host } from '@/config';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('profile'),
+ icon: faUser
+ }]
+ },
+ host,
+ name: null,
+ description: null,
+ birthday: null,
+ location: null,
+ fieldName0: null,
+ fieldValue0: null,
+ fieldName1: null,
+ fieldValue1: null,
+ fieldName2: null,
+ fieldValue2: null,
+ fieldName3: null,
+ fieldValue3: null,
+ avatarId: null,
+ bannerId: null,
+ isBot: false,
+ isCat: false,
+ saving: false,
+ faSave, faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake
+ }
+ },
+
+ created() {
+ this.name = this.$store.state.i.name;
+ this.description = this.$store.state.i.description;
+ this.location = this.$store.state.i.location;
+ this.birthday = this.$store.state.i.birthday;
+ this.avatarId = this.$store.state.i.avatarId;
+ this.bannerId = this.$store.state.i.bannerId;
+ this.isBot = this.$store.state.i.isBot;
+ this.isCat = this.$store.state.i.isCat;
+
+ this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
+ this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
+ this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null;
+ this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null;
+ this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null;
+ this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null;
+ this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null;
+ this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null;
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ changeAvatar(e) {
+ selectFile(e.currentTarget || e.target, this.$t('avatar')).then(file => {
+ os.api('i/update', {
+ avatarId: file.id,
+ });
+ });
+ },
+
+ changeBanner(e) {
+ selectFile(e.currentTarget || e.target, this.$t('banner')).then(file => {
+ os.api('i/update', {
+ bannerId: file.id,
+ });
+ });
+ },
+
+ save(notify) {
+ const fields = [
+ { name: this.fieldName0, value: this.fieldValue0 },
+ { name: this.fieldName1, value: this.fieldValue1 },
+ { name: this.fieldName2, value: this.fieldValue2 },
+ { name: this.fieldName3, value: this.fieldValue3 },
+ ];
+
+ this.saving = true;
+
+ os.api('i/update', {
+ name: this.name || null,
+ description: this.description || null,
+ location: this.location || null,
+ birthday: this.birthday || null,
+ fields,
+ isBot: !!this.isBot,
+ isCat: !!this.isCat,
+ }).then(i => {
+ this.saving = false;
+ this.$store.state.i.avatarId = i.avatarId;
+ this.$store.state.i.avatarUrl = i.avatarUrl;
+ this.$store.state.i.bannerId = i.bannerId;
+ this.$store.state.i.bannerUrl = i.bannerUrl;
+
+ if (notify) {
+ os.success();
+ }
+ }).catch(err => {
+ this.saving = false;
+ os.dialog({
+ type: 'error',
+ text: err.id
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.llvierxe {
+ > ._content {
+ > .header {
+ position: relative;
+ height: 150px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+ border-radius: 5px;
+ border: solid 1px var(--divider);
+ box-sizing: border-box;
+ cursor: pointer;
+
+ > .avatar {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: block;
+ width: 72px;
+ height: 72px;
+ margin: auto;
+ cursor: pointer;
+ box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
+ }
+ }
+
+ > .fields {
+ > .row {
+ > * {
+ display: inline-block;
+ width: 50%;
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue
new file mode 100644
index 0000000000..683cf6dfbe
--- /dev/null
+++ b/src/client/pages/settings/reaction.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
+ <div class="_content">
+ <MkInput v-model:value="reactions" style="font-family: 'Segoe UI Emoji', 'Noto Color Emoji', Roboto, HelveticaNeue, Arial, sans-serif">
+ {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template>
+ </MkInput>
+ <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton>
+ </div>
+ <div class="_footer">
+ <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
+ <MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
+import { faUndo } from '@fortawesome/free-solid-svg-icons';
+import MkInput from '@/components/ui/input.vue';
+import MkButton from '@/components/ui/button.vue';
+import { emojiRegexWithCustom } from '../../../misc/emoji-regex';
+import { defaultSettings } from '@/store';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInput,
+ MkButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('reaction'),
+ icon: faLaugh
+ }]
+ },
+ reactions: this.$store.state.settings.reactions.join(''),
+ changed: false,
+ faLaugh, faSave, faEye, faUndo
+ }
+ },
+
+ computed: {
+ splited(): any {
+ return this.reactions.match(emojiRegexWithCustom);
+ },
+ },
+
+ watch: {
+ reactions: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ save() {
+ this.$store.dispatch('settings/set', { key: 'reactions', value: this.splited });
+ this.changed = false;
+ },
+
+ async preview(ev) {
+ os.popup(await import('@/components/reaction-picker.vue'), {
+ reactions: this.splited,
+ showFocus: false,
+ src: ev.currentTarget || ev.target,
+ }, {}, 'closed');
+ },
+
+ setDefault() {
+ this.reactions = defaultSettings.reactions.join('');
+ },
+
+ async chooseEmoji(ev) {
+ os.pickEmoji(ev.currentTarget || ev.target).then(emoji => {
+ this.reactions += emoji;
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/security.2fa.vue b/src/client/pages/settings/security.2fa.vue
new file mode 100644
index 0000000000..22b3878445
--- /dev/null
+++ b/src/client/pages/settings/security.2fa.vue
@@ -0,0 +1,235 @@
+<template>
+<section class="_card">
+ <div class="_title"><Fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
+ <div class="_content">
+ <MkButton v-if="!data && !$store.state.i.twoFactorEnabled" @click="register">{{ $t('_2fa.registerDevice') }}</MkButton>
+ <template v-if="$store.state.i.twoFactorEnabled">
+ <p>{{ $t('_2fa.alreadyRegistered') }}</p>
+ <MkButton @click="unregister">{{ $t('unregister') }}</MkButton>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ $t('securityKey') }}</h2>
+ <p>{{ $t('_2fa.securityKeyInfo') }}</p>
+ <div class="key-list">
+ <div class="key" v-for="key in $store.state.i.securityKeysList">
+ <h3>{{ key.name }}</h3>
+ <div class="last-used">{{ $t('lastUsed') }}<MkTime :time="key.lastUsed"/></div>
+ <MkButton @click="unregisterKey(key)">{{ $t('unregister') }}</MkButton>
+ </div>
+ </div>
+
+ <MkSwitch v-model:value="usePasswordLessLogin" @update:value="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">{{ $t('passwordLessLogin') }}</MkSwitch>
+
+ <MkInfo warn v-if="registration && registration.error">{{ $t('error') }} {{ registration.error }}</MkInfo>
+ <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('_2fa.registerKey') }}</MkButton>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ $t('tapSecurityKey') }}
+ <Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
+ </li>
+ <li v-if="registration.stage >= 1">
+ <MkForm :disabled="registration.stage != 1 || registration.saving">
+ <MkInput v-model:value="keyName" :max="30">
+ <span>{{ $t('securityKeyName') }}</span>
+ </MkInput>
+ <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $t('registerSecurityKey') }}</MkButton>
+ <Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
+ </MkForm>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="data && !$store.state.i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <i18n-t keypath="_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-t>
+ </li>
+ <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
+ <li>{{ $t('_2fa.step3') }}<br>
+ <MkInput v-model:value="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</MkInput>
+ <MkButton primary @click="submit">{{ $t('done') }}</MkButton>
+ </li>
+ </ol>
+ <MkInfo>{{ $t('_2fa.step4') }}</MkInfo>
+ </div>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import { hostname } from '@/config';
+import { byteify, hexify, stringify } from '@/scripts/2fa';
+import MkButton from '@/components/ui/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkInput from '@/components/ui/input.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInfo, MkInput, MkSwitch
+ },
+ data() {
+ return {
+ data: null,
+ supportsCredentials: !!navigator.credentials,
+ usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
+ registration: null,
+ keyName: '',
+ token: null,
+ faLock
+ };
+ },
+ methods: {
+ register() {
+ os.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register', {
+ password: password
+ }).then(data => {
+ this.data = data;
+ });
+ });
+ },
+
+ unregister() {
+ os.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/unregister', {
+ password: password
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ this.$store.state.i.twoFactorEnabled = false;
+ });
+ });
+ },
+
+ submit() {
+ os.api('i/2fa/done', {
+ token: this.token
+ }).then(() => {
+ os.success();
+ this.$store.state.i.twoFactorEnabled = true;
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ registerKey() {
+ this.registration.saving = true;
+ os.api('i/2fa/key-done', {
+ password: this.registration.password,
+ name: this.keyName,
+ challengeId: this.registration.challengeId,
+ // we convert each 16 bits to a string to serialise
+ clientDataJSON: stringify(this.registration.credential.response.clientDataJSON),
+ attestationObject: hexify(this.registration.credential.response.attestationObject)
+ }).then(key => {
+ this.registration = null;
+ key.lastUsed = new Date();
+ os.success();
+ })
+ },
+
+ unregisterKey(key) {
+ os.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return os.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ });
+ });
+ },
+
+ addSecurityKey() {
+ os.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register-key', {
+ password
+ }).then(registration => {
+ this.registration = {
+ password,
+ challengeId: registration.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: byteify(registration.challenge, 'base64'),
+ rp: {
+ id: hostname,
+ name: 'Misskey'
+ },
+ user: {
+ id: byteify(this.$store.state.i.id, 'ascii'),
+ name: this.$store.state.i.username,
+ displayName: this.$store.state.i.name,
+ },
+ pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
+ timeout: 60000,
+ attestation: 'direct'
+ },
+ saving: true
+ };
+ return navigator.credentials.create({
+ publicKey: this.registration.publicKeyOptions
+ });
+ }).then(credential => {
+ this.registration.credential = credential;
+ this.registration.saving = false;
+ this.registration.stage = 1;
+ }).catch(err => {
+ console.warn('Error while registering?', err);
+ this.registration.error = err.message;
+ this.registration.stage = -1;
+ });
+ });
+ },
+ updatePasswordLessLogin() {
+ os.api('i/2fa/password-less', {
+ value: !!this.usePasswordLessLogin
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue
new file mode 100644
index 0000000000..e56d4ae99d
--- /dev/null
+++ b/src/client/pages/settings/security.vue
@@ -0,0 +1,102 @@
+<template>
+<div>
+ <div class="_section">
+ <X2fa/>
+ </div>
+ <div class="_section">
+ <MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton>
+ </div>
+ <div class="_section">
+ <MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton>
+ <div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import X2fa from './security.2fa.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ X2fa,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('security'),
+ icon: faLock
+ }]
+ },
+ faLock, faSyncAlt
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ async change() {
+ const { canceled: canceled1, result: currentPassword } = await os.dialog({
+ title: this.$t('currentPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await os.dialog({
+ title: this.$t('newPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await os.dialog({
+ title: this.$t('newPasswordRetype'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ os.dialog({
+ type: 'error',
+ text: this.$t('retypedNotMatch')
+ });
+ return;
+ }
+
+ os.apiWithDialog('i/change-password', {
+ currentPassword,
+ newPassword
+ });
+ },
+
+ regenerateToken() {
+ os.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/regenerate_token', {
+ password: password
+ });
+ });
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/sidebar.vue b/src/client/pages/settings/sidebar.vue
new file mode 100644
index 0000000000..e55899df97
--- /dev/null
+++ b/src/client/pages/settings/sidebar.vue
@@ -0,0 +1,110 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <div class="_content">
+ <MkTextarea v-model:value="items" tall>
+ <span>{{ $t('sidebar') }}</span>
+ <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template>
+ </MkTextarea>
+ </div>
+ <div class="_content">
+ <div>{{ $t('display') }}</div>
+ <MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio>
+ <MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio>
+ <!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
+ </div>
+ <div class="_footer">
+ <MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
+ <MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkRadio from '@/components/ui/radio.vue';
+import { defaultDeviceUserSettings } from '@/store';
+import * as os from '@/os';
+import { sidebarDef } from '@/sidebar';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkTextarea,
+ MkRadio,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('sidebar'),
+ icon: faListUl
+ }]
+ },
+ menuDef: sidebarDef,
+ items: '',
+ faSave, faRedo
+ }
+ },
+
+ computed: {
+ splited(): string[] {
+ return this.items.trim().split('\n').filter(x => x.trim() !== '');
+ },
+
+ sidebarDisplay: {
+ get() { return this.$store.state.device.sidebarDisplay; },
+ set(value) { this.$store.commit('device/set', { key: 'sidebarDisplay', value }); }
+ },
+ },
+
+ created() {
+ this.items = this.$store.state.deviceUser.menu.join('\n');
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ async addItem() {
+ const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k));
+ const { canceled, result: item } = await os.dialog({
+ type: null,
+ title: this.$t('addItem'),
+ select: {
+ items: [...menu.map(k => ({
+ value: k, text: this.$t(this.menuDef[k].title)
+ })), ...[{
+ value: '-', text: this.$t('divider')
+ }]]
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.items = [...this.splited, item].join('\n');
+ this.save();
+ },
+
+ save() {
+ this.$store.commit('deviceUser/setMenu', this.splited);
+ },
+
+ reset() {
+ this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu);
+ this.items = this.$store.state.deviceUser.menu.join('\n');
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue
new file mode 100644
index 0000000000..fc6b751fed
--- /dev/null
+++ b/src/client/pages/settings/sounds.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div>
+ <div class="_content">
+ <MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1">
+ <Fa slot="icon" :icon="volumeIcon"/>
+ <span slot="title">{{ $t('volume') }}</span>
+ </MkRange>
+ </div>
+ <div class="_content">
+ <MkSelect v-model:value="sfxNote">
+ <template #label>{{ $t('_sfx.note') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxNoteMy">
+ <template #label>{{ $t('_sfx.noteMy') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxNotification">
+ <template #label>{{ $t('_sfx.notification') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxChat">
+ <template #label>{{ $t('_sfx.chat') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxChatBg">
+ <template #label>{{ $t('_sfx.chatBg') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxAntenna">
+ <template #label>{{ $t('_sfx.antenna') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ <MkSelect v-model:value="sfxChannel">
+ <template #label>{{ $t('_sfx.channel') }}</template>
+ <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+ <template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+ </MkSelect>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
+import MkSelect from '@/components/ui/select.vue';
+import MkRange from '@/components/ui/range.vue';
+import * as os from '@/os';
+
+const sounds = [
+ null,
+ 'syuilo/up',
+ 'syuilo/down',
+ 'syuilo/pope1',
+ 'syuilo/pope2',
+ 'syuilo/waon',
+ 'syuilo/popo',
+ 'syuilo/triple',
+ 'syuilo/poi1',
+ 'syuilo/poi2',
+ 'syuilo/pirori',
+ 'syuilo/pirori-wet',
+ 'syuilo/pirori-square-wet',
+ 'syuilo/square-pico',
+ 'syuilo/reverved',
+ 'syuilo/ryukyu',
+ 'aisha/1',
+ 'aisha/2',
+ 'aisha/3',
+ 'noizenecio/kick_gaba',
+ 'noizenecio/kick_gaba2',
+];
+
+export default defineComponent({
+ components: {
+ MkSelect,
+ MkRange,
+ },
+
+ data() {
+ return {
+ sounds,
+ faMusic, faPlay, faVolumeUp, faVolumeMute,
+ }
+ },
+
+ computed: {
+ sfxVolume: {
+ get() { return this.$store.state.device.sfxVolume; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); }
+ },
+
+ sfxNote: {
+ get() { return this.$store.state.device.sfxNote; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); }
+ },
+
+ sfxNoteMy: {
+ get() { return this.$store.state.device.sfxNoteMy; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); }
+ },
+
+ sfxNotification: {
+ get() { return this.$store.state.device.sfxNotification; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); }
+ },
+
+ sfxChat: {
+ get() { return this.$store.state.device.sfxChat; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); }
+ },
+
+ sfxChatBg: {
+ get() { return this.$store.state.device.sfxChatBg; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); }
+ },
+
+ sfxAntenna: {
+ get() { return this.$store.state.device.sfxAntenna; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
+ },
+
+ sfxChannel: {
+ get() { return this.$store.state.device.sfxChannel; },
+ set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
+ },
+
+ volumeIcon: {
+ get() {
+ return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
+ }
+ }
+ },
+
+ methods: {
+ listen(sound) {
+ const audio = new Audio(`/assets/sounds/${sound}.mp3`);
+ audio.volume = this.$store.state.device.sfxVolume;
+ audio.play();
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue
new file mode 100644
index 0000000000..0571b6c5d1
--- /dev/null
+++ b/src/client/pages/settings/theme.vue
@@ -0,0 +1,499 @@
+<template>
+<div class="_section">
+ <div class="rfqxtzch _card _vMargin">
+ <div class="_content">
+ <div class="darkMode" :class="{ disabled: syncDeviceDarkMode }">
+ <div class="toggleWrapper">
+ <input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/>
+ <label for="dn" class="toggle">
+ <span class="before">{{ $t('light') }}</span>
+ <span class="after">{{ $t('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>
+ <MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch>
+ </div>
+ <div class="_content">
+ <MkSelect v-model:value="lightTheme">
+ <template #label>{{ $t('themeForLightMode') }}</template>
+ <optgroup :label="$t('lightThemes')">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$t('darkThemes')">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </MkSelect>
+ <MkSelect v-model:value="darkTheme">
+ <template #label>{{ $t('themeForDarkMode') }}</template>
+ <optgroup :label="$t('darkThemes')">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$t('lightThemes')">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </MkSelect>
+ <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link>
+ </div>
+ <div class="_content">
+ <MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton>
+ <MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton>
+ </div>
+ </div>
+ <div class="_card _vMargin">
+ <div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div>
+ <div class="_content">
+ <MkTextarea v-model:value="installThemeCode">
+ <span>{{ $t('_theme.code') }}</span>
+ </MkTextarea>
+ <MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton>
+ <MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
+ </div>
+ </div>
+ <div class="_card _vMargin">
+ <div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div>
+ <div class="_content">
+ <MkSelect v-model:value="selectedThemeId">
+ <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </MkSelect>
+ <template v-if="selectedTheme">
+ <MkTextarea readonly tall :value="selectedThemeCode">
+ <span>{{ $t('_theme.code') }}</span>
+ <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template>
+ </MkTextarea>
+ <MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme';
+import { selectFile } from '@/scripts/select-file';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ MkSwitch,
+ MkTextarea,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('theme'),
+ icon: faPalette
+ }]
+ },
+ builtinThemes,
+ installThemeCode: null,
+ selectedThemeId: null,
+ wallpaper: localStorage.getItem('wallpaper'),
+ faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
+ }
+ },
+
+ computed: {
+ themes(): Theme[] {
+ return builtinThemes.concat(this.$store.state.device.themes);
+ },
+
+ installedThemes(): Theme[] {
+ return this.$store.state.device.themes;
+ },
+
+ darkThemes(): Theme[] {
+ return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark');
+ },
+
+ lightThemes(): Theme[] {
+ return this.themes.filter(t => t.base == 'light' || t.kind == 'light');
+ },
+
+ darkTheme: {
+ get() { return this.$store.state.device.darkTheme; },
+ set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); }
+ },
+
+ lightTheme: {
+ get() { return this.$store.state.device.lightTheme; },
+ set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); }
+ },
+
+ darkMode: {
+ get() { return this.$store.state.device.darkMode; },
+ set(value) { this.$store.commit('device/set', { key: 'darkMode', value }); }
+ },
+
+ syncDeviceDarkMode: {
+ get() { return this.$store.state.device.syncDeviceDarkMode; },
+ set(value) { this.$store.commit('device/set', { key: 'syncDeviceDarkMode', value }); }
+ },
+
+ selectedTheme() {
+ if (this.selectedThemeId == null) return null;
+ return this.themes.find(x => x.id === this.selectedThemeId);
+ },
+
+ selectedThemeCode() {
+ if (this.selectedTheme == null) return null;
+ return JSON5.stringify(this.selectedTheme, null, '\t');
+ },
+ },
+
+ watch: {
+ darkTheme() {
+ if (this.$store.state.device.darkMode) {
+ applyTheme(this.themes.find(x => x.id === this.darkTheme));
+ }
+ },
+
+ lightTheme() {
+ if (!this.$store.state.device.darkMode) {
+ applyTheme(this.themes.find(x => x.id === this.lightTheme));
+ }
+ },
+
+ syncDeviceDarkMode() {
+ if (this.$store.state.device.syncDeviceDarkMode) {
+ this.$store.commit('device/set', { key: 'darkMode', value: isDeviceDarkmode() });
+ }
+ },
+
+ wallpaper() {
+ if (this.wallpaper == null) {
+ localStorage.removeItem('wallpaper');
+ } else {
+ localStorage.setItem('wallpaper', this.wallpaper);
+ }
+ location.reload();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ setWallpaper(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ this.wallpaper = file.url;
+ });
+ },
+
+ copyThemeCode() {
+ copyToClipboard(this.selectedThemeCode);
+ os.success();
+ },
+
+ parseThemeCode(code) {
+ let theme;
+
+ try {
+ theme = JSON5.parse(code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$t('_theme.invalid')
+ });
+ return false;
+ }
+ if (!validateTheme(theme)) {
+ os.dialog({
+ type: 'error',
+ text: this.$t('_theme.invalid')
+ });
+ return false;
+ }
+ if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
+ os.dialog({
+ type: 'info',
+ text: this.$t('_theme.alreadyInstalled')
+ });
+ return false;
+ }
+
+ return theme;
+ },
+
+ preview(code) {
+ const theme = this.parseThemeCode(code);
+ if (theme) applyTheme(theme, false);
+ },
+
+ install(code) {
+ const theme = this.parseThemeCode(code);
+ if (!theme) return;
+ const themes = this.$store.state.device.themes.concat(theme);
+ this.$store.commit('device/set', {
+ key: 'themes', value: themes
+ });
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: theme.name })
+ });
+ },
+
+ uninstall() {
+ const theme = this.selectedTheme;
+ const themes = this.$store.state.device.themes.filter(t => t.id != theme.id);
+ this.$store.commit('device/set', {
+ key: 'themes', value: themes
+ });
+ os.success();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rfqxtzch {
+ > ._content {
+ > .darkMode {
+ position: relative;
+ padding: 32px 0;
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ .toggleWrapper {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ overflow: hidden;
+ padding: 0 100px;
+ transform: translate3d(-50%, -50%, 0);
+
+ input {
+ position: absolute;
+ left: -99em;
+ }
+ }
+
+ .toggle {
+ cursor: pointer;
+ display: inline-block;
+ position: relative;
+ width: 90px;
+ height: 50px;
+ background-color: #83D8FF;
+ border-radius: 90px - 6;
+ transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+
+ > .before, > .after {
+ position: absolute;
+ top: 15px;
+ font-size: 18px;
+ transition: color 1s ease;
+ }
+
+ > .before {
+ left: -70px;
+ color: var(--accent);
+ }
+
+ > .after {
+ right: -68px;
+ color: var(--fg);
+ }
+ }
+
+ .toggle__handler {
+ display: inline-block;
+ position: relative;
+ z-index: 1;
+ top: 3px;
+ left: 3px;
+ width: 50px - 6;
+ height: 50px - 6;
+ background-color: #FFCF96;
+ border-radius: 50px;
+ box-shadow: 0 2px 6px rgba(0,0,0,.3);
+ transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
+ transform: rotate(-45deg);
+
+ .crater {
+ position: absolute;
+ background-color: #E8CDA5;
+ opacity: 0;
+ transition: opacity 200ms ease-in-out !important;
+ border-radius: 100%;
+ }
+
+ .crater--1 {
+ top: 18px;
+ left: 10px;
+ width: 4px;
+ height: 4px;
+ }
+
+ .crater--2 {
+ top: 28px;
+ left: 22px;
+ width: 6px;
+ height: 6px;
+ }
+
+ .crater--3 {
+ top: 10px;
+ left: 25px;
+ width: 8px;
+ height: 8px;
+ }
+ }
+
+ .star {
+ position: absolute;
+ background-color: #ffffff;
+ transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ border-radius: 50%;
+ }
+
+ .star--1 {
+ top: 10px;
+ left: 35px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--2 {
+ top: 18px;
+ left: 28px;
+ z-index: 1;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--3 {
+ top: 27px;
+ left: 40px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 0;
+ transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--4 {
+ top: 16px;
+ left: 11px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--5 {
+ top: 32px;
+ left: 17px;
+ z-index: 0;
+ width: 3px;
+ height: 3px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--6 {
+ top: 36px;
+ left: 28px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ input:checked {
+ + .toggle {
+ background-color: #749DD6;
+
+ > .before {
+ color: var(--fg);
+ }
+
+ > .after {
+ color: var(--accent);
+ }
+
+ .toggle__handler {
+ background-color: #FFE5B5;
+ transform: translate3d(40px, 0, 0) rotate(0);
+
+ .crater { opacity: 1; }
+ }
+
+ .star--1 {
+ width: 2px;
+ height: 2px;
+ }
+
+ .star--2 {
+ width: 4px;
+ height: 4px;
+ transform: translate3d(-5px, 0, 0);
+ }
+
+ .star--3 {
+ width: 2px;
+ height: 2px;
+ transform: translate3d(-7px, 0, 0);
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 1;
+ transform: translate3d(0,0,0);
+ }
+
+ .star--4 {
+ transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--5 {
+ transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--6 {
+ transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue
new file mode 100644
index 0000000000..a517536a1c
--- /dev/null
+++ b/src/client/pages/settings/word-mute.vue
@@ -0,0 +1,101 @@
+<template>
+<div class="_section">
+ <div class="_card">
+ <MkTab v-model:value="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/>
+ <div class="_content">
+ <div v-show="tab === 'soft'">
+ <MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo>
+ <MkTextarea v-model:value="softMutedWords">
+ <span>{{ $t('_wordMute.muteWords') }}</span>
+ <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
+ </MkTextarea>
+ </div>
+ <div v-show="tab === 'hard'">
+ <MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo>
+ <MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;">
+ <span>{{ $t('_wordMute.muteWords') }}</span>
+ <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
+ </MkTextarea>
+ <div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div>
+ </div>
+ </div>
+ <div class="_footer">
+ <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkTextarea,
+ MkTab,
+ MkInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ header: [{
+ title: this.$t('wordMute'),
+ icon: faCommentSlash
+ }]
+ },
+ tab: 'soft',
+ softMutedWords: '',
+ hardMutedWords: '',
+ hardWordMutedNotesCount: null,
+ changed: false,
+ faSave,
+ }
+ },
+
+ watch: {
+ softMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ hardMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ },
+
+ async created() {
+ this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n');
+ this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n');
+
+ this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ async save() {
+ this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) });
+ await os.api('i/update', {
+ mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.changed = false;
+ },
+ }
+});
+</script>