summaryrefslogtreecommitdiff
path: root/src/client/pages/settings
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-01-30 04:37:25 +0900
committerGitHub <noreply@github.com>2020-01-30 04:37:25 +0900
commitf6154dc0af1a0d65819e87240f4385f9573095cb (patch)
tree699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/pages/settings
parentAdd Event activity-type support (#5785) (diff)
downloadmisskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/pages/settings')
-rw-r--r--src/client/pages/settings/2fa.vue264
-rw-r--r--src/client/pages/settings/drive.vue212
-rw-r--r--src/client/pages/settings/general.vue108
-rw-r--r--src/client/pages/settings/import-export.vue121
-rw-r--r--src/client/pages/settings/index.vue94
-rw-r--r--src/client/pages/settings/integration.vue122
-rw-r--r--src/client/pages/settings/mute-block.vue76
-rw-r--r--src/client/pages/settings/privacy.vue69
-rw-r--r--src/client/pages/settings/profile.vue246
-rw-r--r--src/client/pages/settings/reaction.vue62
-rw-r--r--src/client/pages/settings/security.vue87
-rw-r--r--src/client/pages/settings/theme.vue76
12 files changed, 1537 insertions, 0 deletions
diff --git a/src/client/pages/settings/2fa.vue b/src/client/pages/settings/2fa.vue
new file mode 100644
index 0000000000..7163f2ece4
--- /dev/null
+++ b/src/client/pages/settings/2fa.vue
@@ -0,0 +1,264 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
+ <div class="_content">
+ <p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p>
+ <template v-if="$store.state.i.twoFactorEnabled">
+ <h2 class="heading">{{ $t('totp-header') }}</h2>
+ <p>{{ $t('already-registered') }}</p>
+ <mk-button @click="unregister">{{ $t('unregister') }}</mk-button>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ $t('security-key-header') }}</h2>
+ <p>{{ $t('security-key') }}</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('last-used') }}
+ <mk-time :time="key.lastUsed"/>
+ </div>
+ <mk-button @click="unregisterKey(key)">
+ {{ $t('unregister') }}
+ </mk-button>
+ </div>
+ </div>
+
+ <mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
+ {{ $t('use-password-less-login') }}
+ </mk-switch>
+
+ <mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info>
+ <mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ $t('activate-key') }}
+ <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
+ </li>
+ <li v-if="registration.stage >= 1">
+ <mk-form :disabled="registration.stage != 1 || registration.saving">
+ <mk-input v-model="keyName" :max="30">
+ <span>{{ $t('security-key-name') }}</span>
+ </mk-input>
+ <mk-button @click="registerKey" :disabled="this.keyName.length == 0">
+ {{ $t('register-security-key') }}
+ </mk-button>
+ <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
+ </mk-form>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="data && !$store.state.i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <i18n path="_2fa.step1" tag="span">
+ <a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a>
+ <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a>
+ </i18n>
+ </li>
+ <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
+ <li>{{ $t('_2fa.step3') }}<br>
+ <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input>
+ <mk-button primary @click="submit">{{ $t('done') }}</mk-button>
+ </li>
+ </ol>
+ <mk-info>{{ $t('_2fa.step4') }}</mk-info>
+ </div>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import { hostname } from '../../config';
+import { hexifyAB } from '../../scripts/2fa';
+import MkButton from '../../components/ui/button.vue';
+import MkInfo from '../../components/ui/info.vue';
+import MkInput from '../../components/ui/input.vue';
+
+function stringifyAB(buffer) {
+ return String.fromCharCode.apply(null, new Uint8Array(buffer));
+}
+
+export default Vue.extend({
+ i18n,
+ components: {
+ MkButton, MkInfo, MkInput
+ },
+ data() {
+ return {
+ data: null,
+ supportsCredentials: !!navigator.credentials,
+ usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
+ registration: null,
+ keyName: '',
+ token: null,
+ faLock
+ };
+ },
+ methods: {
+ register() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/register', {
+ password: password
+ }).then(data => {
+ this.data = data;
+ });
+ });
+ },
+
+ unregister() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/unregister', {
+ password: password
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$store.state.i.twoFactorEnabled = false;
+ });
+ });
+ },
+
+ submit() {
+ this.$root.api('i/2fa/done', {
+ token: this.token
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$store.state.i.twoFactorEnabled = true;
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ iconOnly: true, autoClose: true
+ });
+ });
+ },
+
+ registerKey() {
+ this.registration.saving = true;
+ this.$root.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: stringifyAB(this.registration.credential.response.clientDataJSON),
+ attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
+ }).then(key => {
+ this.registration = null;
+ key.lastUsed = new Date();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ })
+ },
+
+ unregisterKey(key) {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return this.$root.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ },
+
+ addSecurityKey() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/register-key', {
+ password
+ }).then(registration => {
+ this.registration = {
+ password,
+ challengeId: registration.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: Buffer.from(
+ registration.challenge
+ .replace(/\-/g, "+")
+ .replace(/_/g, "/"),
+ 'base64'
+ ),
+ rp: {
+ id: hostname,
+ name: 'Misskey'
+ },
+ user: {
+ id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
+ 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() {
+ this.$root.api('i/2fa/password-less', {
+ value: !!this.usePasswordLessLogin
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue
new file mode 100644
index 0000000000..d0c18a07e5
--- /dev/null
+++ b/src/client/pages/settings/drive.vue
@@ -0,0 +1,212 @@
+<template>
+<section class="mk-settings-page-drive _section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div>
+ <div class="_content">
+ <mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive">
+ <div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }">
+ <x-file-thumbnail class="thumbnail" :file="file" fit="cover"/>
+ <div class="body">
+ <p class="name">
+ <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
+ <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
+ </p>
+ <footer>
+ <span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span>
+ <span class="separator"></span>
+ <span class="data-size">{{ file.size | bytes }}</span>
+ <span class="separator"></span>
+ <span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span>
+ <template v-if="file.isSensitive">
+ <span class="separator"></span>
+ <span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span>
+ </template>
+ </footer>
+ </div>
+ </div>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button>
+ <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons';
+import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import XFileTypeIcon from '../../components/file-type-icon.vue';
+import XFileThumbnail from '../../components/drive-file-thumbnail.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XFileTypeIcon,
+ XFileThumbnail,
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ selected: null,
+ connection: null,
+ drivePagination: {
+ endpoint: 'drive/files',
+ limit: 10,
+ },
+ faCloud, faClock, faEyeSlash, faDownload, faTrashAlt
+ }
+ },
+
+ created() {
+ this.connection = this.$root.stream.useSharedConnection('drive');
+
+ this.connection.on('fileCreated', this.onStreamDriveFileCreated);
+ this.connection.on('fileUpdated', this.onStreamDriveFileUpdated);
+ this.connection.on('fileDeleted', this.onStreamDriveFileDeleted);
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ onStreamDriveFileCreated(file) {
+ this.$refs.drive.prepend(file);
+ },
+
+ onStreamDriveFileUpdated(file) {
+ // TODO
+ },
+
+ onStreamDriveFileDeleted(fileId) {
+ this.$refs.drive.remove(x => x.id === fileId);
+ },
+
+ download() {
+ window.open(this.selected.url, '_blank');
+ },
+
+ async del() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.$root.api('drive/files/delete', {
+ fileId: this.selected.id
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-drive {
+ > ._content {
+ max-height: 350px;
+ overflow: auto;
+
+ > .drive {
+ > .file {
+ display: grid;
+ margin: 0 auto;
+ grid-template-columns: 64px 1fr;
+ grid-column-gap: 10px;
+ cursor: pointer;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 16px;
+ }
+
+ > .thumbnail {
+ width: 64px;
+ height: 64px;
+ }
+
+ > .body {
+ display: block;
+ word-break: break-all;
+ padding-top: 4px;
+
+ > .name {
+ display: block;
+ margin: 0;
+ padding: 0;
+ font-size: 0.9em;
+ font-weight: bold;
+ word-break: break-word;
+
+ > .ext {
+ opacity: 0.5;
+ }
+ }
+
+ > .tags {
+ display: block;
+ margin: 4px 0 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.5em;
+
+ > .tag {
+ display: inline-block;
+ margin: 0 5px 0 0;
+ padding: 1px 5px;
+ border-radius: 2px;
+ }
+ }
+
+ > footer {
+ display: block;
+ margin: 4px 0 0 0;
+ font-size: 0.7em;
+
+ > .separator {
+ padding: 0 4px;
+ }
+
+ > .type {
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ }
+ }
+
+ > .data-size {
+ opacity: 0.7;
+ }
+
+ > .created-at {
+ opacity: 0.7;
+
+ > [data-icon] {
+ margin-right: 2px;
+ }
+ }
+
+ > .nsfw {
+ color: #bf4633;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue
new file mode 100644
index 0000000000..6b63da742c
--- /dev/null
+++ b/src/client/pages/settings/general.vue
@@ -0,0 +1,108 @@
+<template>
+<section class="mk-settings-page-general _section">
+ <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
+ <div class="_content">
+ <mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;">
+ <span>{{ $t('wallpaper') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+ <mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button>
+ </div>
+ <div class="_content">
+ <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
+ {{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template>
+ </mk-switch>
+ </div>
+ <div class="_content">
+ <mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button>
+ <mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button>
+ <mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
+import MkInput from '../../components/ui/input.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkInput,
+ MkButton,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ wallpaperUploading: false,
+ faImage, faCog
+ }
+ },
+
+ computed: {
+ wallpaper: {
+ get() { return this.$store.state.settings.wallpaper; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); }
+ },
+ },
+
+ methods: {
+ onWallpaperChange([file]) {
+ this.wallpaperUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.wallpaper = f.url;
+ this.wallpaperUploading = false;
+ document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`;
+ })
+ .catch(e => {
+ this.wallpaperUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ delWallpaper() {
+ this.wallpaper = null;
+ document.documentElement.style.backgroundImage = 'none';
+ },
+
+ onChangeAutoWatch(v) {
+ this.$root.api('i/update', {
+ autoWatch: v
+ });
+ },
+
+ readAllUnreadNotes() {
+ this.$root.api('i/read_all_unread_notes');
+ },
+
+ readAllMessagingMessages() {
+ this.$root.api('i/read_all_messaging_messages');
+ },
+
+ readAllNotifications() {
+ this.$root.api('notifications/mark_all_as_read');
+ }
+ }
+});
+</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..5714aabbf6
--- /dev/null
+++ b/src/client/pages/settings/import-export.vue
@@ -0,0 +1,121 @@
+<template>
+<section class="mk-settings-page-import-export _section">
+ <div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
+ <div class="_content">
+ <input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
+ <mk-select v-model="exportTarget" style="margin-top: 0;">
+ <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>
+ </mk-select>
+ <mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button>
+ <mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue 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 i18n from '../../i18n';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ MkSelect,
+ },
+
+ data() {
+ return {
+ exportTarget: 'notes',
+ faDownload, faUpload, faBoxes
+ }
+ },
+
+ methods: {
+ doExport() {
+ this.$root.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(() => {
+ this.$root.dialog({
+ type: 'info',
+ text: this.$t('exportRequested')
+ });
+ }).catch((e: any) => {
+ this.$root.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 = this.$root.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 => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ })
+ .finally(() => {
+ dialog.close();
+ });
+ },
+
+ reqImport(file) {
+ this.$root.api(
+ this.exportTarget == 'following' ? 'i/import-following' :
+ this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
+ null, {
+ fileId: file.id
+ }).then(() => {
+ this.$root.dialog({
+ type: 'info',
+ text: this.$t('importRequested')
+ });
+ }).catch((e: any) => {
+ this.$root.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..1a00c65760
--- /dev/null
+++ b/src/client/pages/settings/index.vue
@@ -0,0 +1,94 @@
+<template>
+<div class="mk-settings-page">
+ <portal to="icon"><fa :icon="faCog"/></portal>
+ <portal to="title">{{ $t('settings') }}</portal>
+
+ <x-profile-setting/>
+ <x-privacy-setting/>
+ <x-reaction-setting/>
+ <x-theme/>
+ <x-import-export/>
+ <x-drive/>
+ <x-general/>
+ <x-mute-block/>
+ <x-security/>
+ <x-2fa/>
+ <x-integration/>
+
+ <mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button>
+ <mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import XProfileSetting from './profile.vue';
+import XPrivacySetting from './privacy.vue';
+import XImportExport from './import-export.vue';
+import XDrive from './drive.vue';
+import XGeneral from './general.vue';
+import XReactionSetting from './reaction.vue';
+import XMuteBlock from './mute-block.vue';
+import XSecurity from './security.vue';
+import XTheme from './theme.vue';
+import X2fa from './2fa.vue';
+import XIntegration from './integration.vue';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('settings') as string
+ };
+ },
+
+ components: {
+ XProfileSetting,
+ XPrivacySetting,
+ XImportExport,
+ XDrive,
+ XGeneral,
+ XReactionSetting,
+ XMuteBlock,
+ XSecurity,
+ XTheme,
+ X2fa,
+ XIntegration,
+ MkButton,
+ },
+
+ data() {
+ return {
+ faCog
+ }
+ },
+
+ 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>
+
+<style lang="scss" scoped>
+.mk-settings-page {
+ > .logout,
+ > .cacheClear {
+ margin: 8px auto;
+ }
+}
+</style>
diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue
new file mode 100644
index 0000000000..b156e13027
--- /dev/null
+++ b/src/client/pages/settings/integration.vue
@@ -0,0 +1,122 @@
+<template>
+<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
+ <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
+ <div class="_content" v-if="enableTwitterIntegration">
+ <header><fa :icon="faTwitter"/> Twitter</header>
+ <p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
+ <mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button>
+ </div>
+
+ <div class="_content" v-if="enableDiscordIntegration">
+ <header><fa :icon="faDiscord"/> Discord</header>
+ <p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p>
+ <mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button>
+ </div>
+
+ <div class="_content" v-if="enableGithubIntegration">
+ <header><fa :icon="faGithub"/> GitHub</header>
+ <p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p>
+ <mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
+import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
+import i18n from '../../i18n';
+import { apiUrl } from '../../config';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ apiUrl,
+ twitterForm: null,
+ discordForm: null,
+ githubForm: null,
+ enableTwitterIntegration: false,
+ enableDiscordIntegration: false,
+ enableGithubIntegration: false,
+ faShareAlt, faTwitter, faDiscord, faGithub
+ };
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ });
+ },
+
+ mounted() {
+ if (!document.cookie.match(/i=(\w+)/)) {
+ document.cookie = `i=${this.$store.state.i.token}; path=/;` +
+ ` domain=${document.location.hostname}; max-age=31536000;` +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+ }
+ this.$watch('$store.state.i', () => {
+ if (this.$store.state.i.twitter) {
+ if (this.twitterForm) this.twitterForm.close();
+ }
+ if (this.$store.state.i.discord) {
+ if (this.discordForm) this.discordForm.close();
+ }
+ if (this.$store.state.i.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..109b33d4f5
--- /dev/null
+++ b/src/client/pages/settings/mute-block.vue
@@ -0,0 +1,76 @@
+<template>
+<section class="mk-settings-page-mute-block _section">
+ <div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div>
+ <div class="_content">
+ <span>{{ $t('mutedUsers') }}</span>
+ <mk-pagination :pagination="mutingPagination" class="muting">
+ <template #empty><span>{{ $t('noUsers') }}</span></template>
+ <template #default="{items}">
+ <div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i">
+ <router-link class="name" :to="mute.mutee | userPage">
+ <mk-acct :user="mute.mutee"/>
+ </router-link>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_content">
+ <span>{{ $t('blockedUsers') }}</span>
+ <mk-pagination :pagination="blockingPagination" class="blocking">
+ <template #empty><span>{{ $t('noUsers') }}</span></template>
+ <template #default="{items}">
+ <div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i">
+ <router-link class="name" :to="block.blockee | userPage">
+ <mk-acct :user="block.blockee"/>
+ </router-link>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBan } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '../../components/ui/pagination.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkPagination,
+ },
+
+ data() {
+ return {
+ mutingPagination: {
+ endpoint: 'mute/list',
+ limit: 10,
+ },
+ blockingPagination: {
+ endpoint: 'blocking/list',
+ limit: 10,
+ },
+ faBan
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-mute-block {
+ > ._content {
+ max-height: 350px;
+ overflow: auto;
+
+ > .muting,
+ > .blocking {
+ > .empty {
+ opacity: 0.5 !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue
new file mode 100644
index 0000000000..0fc67d5b7d
--- /dev/null
+++ b/src/client/pages/settings/privacy.vue
@@ -0,0 +1,69 @@
+<template>
+<section class="mk-settings-page-privacy _section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div>
+ <div class="_content">
+ <mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch>
+ <mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch>
+ </div>
+ <div class="_content">
+ <mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;">
+ <template #label>{{ $t('defaultNoteVisibility') }}</template>
+ <option value="public">{{ $t('_visibility.public') }}</option>
+ <option value="followers">{{ $t('_visibility.followers') }}</option>
+ <option value="specified">{{ $t('_visibility.specified') }}</option>
+ </mk-select>
+ <mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkSelect,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ isLocked: false,
+ autoAcceptFollowed: false,
+ faLock
+ }
+ },
+
+ computed: {
+ defaultNoteVisibility: {
+ get() { return this.$store.state.settings.defaultNoteVisibility; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', 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;
+ },
+
+ methods: {
+ save() {
+ this.$root.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..e6219c2d56
--- /dev/null
+++ b/src/client/pages/settings/profile.vue
@@ -0,0 +1,246 @@
+<template>
+<section class="mk-settings-page-profile _section">
+ <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">
+ <mk-input v-model="name" :max="30">
+ <span>{{ $t('_profile.name') }}</span>
+ </mk-input>
+
+ <mk-textarea v-model="description" :max="500">
+ <span>{{ $t('_profile.description') }}</span>
+ <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
+ </mk-textarea>
+
+ <mk-input v-model="location">
+ <span>{{ $t('location') }}</span>
+ <template #prefix><fa :icon="faMapMarkerAlt"/></template>
+ </mk-input>
+
+ <mk-input v-model="birthday" type="date">
+ <template #title>{{ $t('birthday') }}</template>
+ <template #prefix><fa :icon="faBirthdayCake"/></template>
+ </mk-input>
+
+ <mk-input type="file" @change="onAvatarChange">
+ <span>{{ $t('avatar') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+
+ <mk-input type="file" @change="onBannerChange">
+ <span>{{ $t('banner') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+
+ <details class="fields">
+ <summary>{{ $t('_profile.metadata') }}</summary>
+ <div class="row">
+ <mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ </details>
+
+ <mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch>
+ <mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch>
+ </div>
+ <div class="_footer">
+ <mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faUnlockAlt, faCogs, faImage, 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 i18n from '../../i18n';
+import { apiUrl, host } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ 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,
+ avatarUploading: false,
+ bannerUploading: false,
+ faSave, faUnlockAlt, faCogs, faImage, 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;
+ },
+
+ methods: {
+ onAvatarChange([file]) {
+ this.avatarUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.avatarId = f.id;
+ this.avatarUploading = false;
+ })
+ .catch(e => {
+ this.avatarUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ onBannerChange([file]) {
+ this.bannerUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.bannerId = f.id;
+ this.bannerUploading = false;
+ })
+ .catch(e => {
+ this.bannerUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ 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;
+
+ this.$root.api('i/update', {
+ name: this.name || null,
+ description: this.description || null,
+ location: this.location || null,
+ birthday: this.birthday || null,
+ avatarId: this.avatarId || undefined,
+ bannerId: this.bannerId || undefined,
+ 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) {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }
+ }).catch(err => {
+ this.saving = false;
+ this.$root.dialog({
+ type: 'error',
+ text: err.id
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-profile {
+ > ._content {
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > .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..310237b5fd
--- /dev/null
+++ b/src/client/pages/settings/reaction.vue
@@ -0,0 +1,62 @@
+<template>
+<section class="mk-settings-page-reaction _section">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ <mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkReactionPicker from '../../components/reaction-picker.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkTextarea,
+ MkButton,
+ },
+
+ data() {
+ return {
+ reactions: this.$store.state.settings.reactions.join('\n'),
+ changed: false,
+ faLaugh, faSave, faEye
+ }
+ },
+
+ watch: {
+ reactions() {
+ this.changed = true;
+ }
+ },
+
+ methods: {
+ save() {
+ this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') });
+ this.changed = false;
+ },
+
+ preview(ev) {
+ const picker = this.$root.new(MkReactionPicker, {
+ source: ev.currentTarget || ev.target,
+ reactions: this.reactions.trim().split('\n'),
+ showFocus: false,
+ });
+ picker.$once('chosen', reaction => {
+ picker.close();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue
new file mode 100644
index 0000000000..ecf9c01dd5
--- /dev/null
+++ b/src/client/pages/settings/security.vue
@@ -0,0 +1,87 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div>
+ <div class="_content">
+ <mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ faLock
+ }
+ },
+
+ methods: {
+ async change() {
+ const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({
+ title: this.$t('currentPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await this.$root.dialog({
+ title: this.$t('newPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({
+ title: this.$t('newPasswordRetype'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('retypedNotMatch')
+ });
+ return;
+ }
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ iconOnly: true
+ });
+
+ this.$root.api('i/change-password', {
+ currentPassword,
+ newPassword
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue
new file mode 100644
index 0000000000..71628ab2b9
--- /dev/null
+++ b/src/client/pages/settings/theme.vue
@@ -0,0 +1,76 @@
+<template>
+<section class="mk-settings-page-theme _section">
+ <div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div>
+ <div class="_content">
+ <mk-select v-model="theme" :placeholder="$t('theme')">
+ <template #label>{{ $t('theme') }}</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>
+ </mk-select>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPalette } from '@fortawesome/free-solid-svg-icons';
+import MkInput from '../../components/ui/input.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkSelect from '../../components/ui/select.vue';
+import i18n from '../../i18n';
+import { Theme, builtinThemes, applyTheme } from '../../theme';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkInput,
+ MkButton,
+ MkSelect,
+ },
+
+ data() {
+ return {
+ wallpaperUploading: false,
+ faPalette
+ }
+ },
+
+ 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');
+ },
+
+ theme: {
+ get() { return this.$store.state.device.theme; },
+ set(value) { this.$store.commit('device/set', { key: 'theme', value }); }
+ },
+ },
+
+ watch: {
+ theme() {
+ applyTheme(this.themes.find(x => x.id === this.theme));
+ }
+ },
+
+ methods: {
+
+ }
+});
+</script>